diff --git a/packages/web/src/components/CopyableId.tsx b/packages/web/src/components/CopyableId.tsx
new file mode 100644
index 00000000..a19b95de
--- /dev/null
+++ b/packages/web/src/components/CopyableId.tsx
@@ -0,0 +1,43 @@
+import { useState } from 'react';
+import { Copy, Check } from 'lucide-react';
+import { shortId, copyText } from '../lib/clipboard';
+
+interface CopyableIdProps {
+ id: string;
+ /** Characters of the id to show before truncating (default 8). */
+ length?: number;
+ label?: string;
+ testId?: string;
+}
+
+/**
+ * Click-to-copy id chip (#23): shows a short id + copy icon; clicking copies the
+ * FULL id and flashes a transient "Copied!" confirmation. Reusable anywhere an
+ * id is shown (inspector, modals, lists).
+ */
+export function CopyableId({ id, length = 8, label = 'ID', testId = 'copyable-id' }: CopyableIdProps) {
+ const [copied, setCopied] = useState(false);
+ if (!id) return null;
+ const onCopy = async () => {
+ const ok = await copyText(id);
+ if (ok) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1400);
+ }
+ };
+ return (
+
+ );
+}
diff --git a/packages/web/src/components/NodeInspector.tsx b/packages/web/src/components/NodeInspector.tsx
index 3b3e7fcd..62dab7e2 100644
--- a/packages/web/src/components/NodeInspector.tsx
+++ b/packages/web/src/components/NodeInspector.tsx
@@ -4,6 +4,7 @@ import { useGraph } from '../contexts/GraphContext';
import { getTypeConfig, getStatusConfig } from '../constants/workItemConstants';
import type { WorkItemType } from '../constants/workItemConstants';
import { NodeSubgraphPreview } from './NodeSubgraphPreview';
+import { CopyableId } from './CopyableId';
// Heavy (markdown + Prism) — lazy so it's out of the main bundle until a node's
// contents are first opened.
@@ -50,6 +51,7 @@ export function NodeInspector({ node, onClose, compact = false, rootTestId = 'no
{typeCfg.label}
{node.title}
+