shelley/ui: enhance gitinfo notification with worktree and copy button

Philip Zeyliger and Shelley created

Prompt: I want the following user notification to (a) put a copy button next to the hash and (b) include the working directory (worktree root or git root) that we're looking at.

- Add working directory (worktree) display at the beginning of the message
- Add copy button next to the commit hash for easy copying
- Show checkmark briefly after copying to indicate success
- Truncate long commit subjects with ellipsis and show full text on hover
- Style commit hash with monospace font and subtle background
- Extract GitInfoMessage into its own component for clarity

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

ui/src/components/Message.tsx | 255 ++++++++++++++++++++++++++----------
1 file changed, 185 insertions(+), 70 deletions(-)

Detailed changes

ui/src/components/Message.tsx 🔗

@@ -31,85 +31,200 @@ interface MessageProps {
   onCommentTextChange?: (text: string) => void;
 }
 
-function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProps) {
-  // Hide system messages from the UI
-  if (message.type === "system") {
+// Copy icon for the commit hash copy button
+const CopyIcon = () => (
+  <svg
+    width="12"
+    height="12"
+    viewBox="0 0 24 24"
+    fill="none"
+    stroke="currentColor"
+    strokeWidth="2"
+    strokeLinecap="round"
+    strokeLinejoin="round"
+    style={{ verticalAlign: "middle" }}
+  >
+    <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
+    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
+  </svg>
+);
+
+const CheckIcon = () => (
+  <svg
+    width="12"
+    height="12"
+    viewBox="0 0 24 24"
+    fill="none"
+    stroke="currentColor"
+    strokeWidth="2"
+    strokeLinecap="round"
+    strokeLinejoin="round"
+    style={{ verticalAlign: "middle" }}
+  >
+    <polyline points="20 6 9 17 4 12" />
+  </svg>
+);
+
+// GitInfoMessage renders a compact git state notification
+function GitInfoMessage({
+  message,
+  onOpenDiffViewer,
+}: {
+  message: MessageType;
+  onOpenDiffViewer?: (commit: string) => void;
+}) {
+  const [copied, setCopied] = useState(false);
+
+  // Parse user_data which contains structured git state info
+  let commitHash: string | null = null;
+  let subject: string | null = null;
+  let branch: string | null = null;
+  let worktree: string | null = null;
+
+  if (message.user_data) {
+    try {
+      const userData =
+        typeof message.user_data === "string" ? JSON.parse(message.user_data) : message.user_data;
+      if (userData.commit) {
+        commitHash = userData.commit;
+      }
+      if (userData.subject) {
+        subject = userData.subject;
+      }
+      if (userData.branch) {
+        branch = userData.branch;
+      }
+      if (userData.worktree) {
+        worktree = userData.worktree;
+      }
+    } catch (err) {
+      console.error("Failed to parse gitinfo user_data:", err);
+    }
+  }
+
+  if (!commitHash) {
     return null;
   }
 
-  // Render gitinfo messages as compact status updates
-  if (message.type === "gitinfo") {
-    // Parse user_data which contains structured git state info
-    let commitHash: string | null = null;
-    let subject: string | null = null;
-    let branch: string | null = null;
-
-    if (message.user_data) {
-      try {
-        const userData =
-          typeof message.user_data === "string" ? JSON.parse(message.user_data) : message.user_data;
-        if (userData.commit) {
-          commitHash = userData.commit;
-        }
-        if (userData.subject) {
-          subject = userData.subject;
-        }
-        if (userData.branch) {
-          branch = userData.branch;
-        }
-      } catch (err) {
-        console.error("Failed to parse gitinfo user_data:", err);
-      }
+  const canShowDiff = commitHash && onOpenDiffViewer;
+
+  const handleDiffClick = () => {
+    if (commitHash && onOpenDiffViewer) {
+      onOpenDiffViewer(commitHash);
     }
+  };
 
-    if (!commitHash) {
-      return null;
+  const handleCopyHash = (e: React.MouseEvent) => {
+    e.preventDefault();
+    if (commitHash) {
+      navigator.clipboard.writeText(commitHash).then(() => {
+        setCopied(true);
+        setTimeout(() => setCopied(false), 1500);
+      });
     }
+  };
 
-    const canShowDiff = commitHash && onOpenDiffViewer;
+  // Truncate subject if too long
+  const truncatedSubject = subject && subject.length > 40 ? subject.slice(0, 37) + "..." : subject;
 
-    const handleDiffClick = () => {
-      if (commitHash && onOpenDiffViewer) {
-        onOpenDiffViewer(commitHash);
-      }
-    };
+  return (
+    <div
+      className="message message-gitinfo"
+      data-testid="message-gitinfo"
+      style={{
+        padding: "0.4rem 1rem",
+        fontSize: "0.8rem",
+        color: "var(--text-secondary)",
+        textAlign: "center",
+        fontStyle: "italic",
+      }}
+    >
+      <span>
+        {worktree && (
+          <span
+            style={{
+              fontFamily: "monospace",
+              fontSize: "0.75rem",
+              marginRight: "0.5em",
+            }}
+          >
+            {worktree}
+          </span>
+        )}
+        {branch && (
+          <span
+            style={{
+              fontWeight: 500,
+              fontStyle: "normal",
+            }}
+          >
+            {branch}
+          </span>
+        )}
+        {branch ? " now at " : "now at "}
+        <code
+          style={{
+            fontFamily: "monospace",
+            fontSize: "0.75rem",
+            background: "var(--bg-tertiary)",
+            padding: "0.1em 0.3em",
+            borderRadius: "3px",
+          }}
+        >
+          {commitHash}
+        </code>
+        <button
+          onClick={handleCopyHash}
+          title="Copy commit hash"
+          style={{
+            background: "none",
+            border: "none",
+            padding: "0.1em 0.3em",
+            cursor: "pointer",
+            color: copied ? "var(--success-color, #22c55e)" : "var(--text-tertiary)",
+            verticalAlign: "middle",
+            marginLeft: "0.2em",
+          }}
+        >
+          {copied ? <CheckIcon /> : <CopyIcon />}
+        </button>
+        {truncatedSubject && (
+          <span style={{ marginLeft: "0.3em" }} title={subject || undefined}>
+            "{truncatedSubject}"
+          </span>
+        )}
+        {canShowDiff && (
+          <>
+            {" "}
+            <a
+              href="#"
+              onClick={(e) => {
+                e.preventDefault();
+                handleDiffClick();
+              }}
+              style={{
+                color: "var(--link-color, #0066cc)",
+                textDecoration: "underline",
+              }}
+            >
+              diff
+            </a>
+          </>
+        )}
+      </span>
+    </div>
+  );
+}
 
-    return (
-      <div
-        className="message message-gitinfo"
-        data-testid="message-gitinfo"
-        style={{
-          padding: "0.4rem 1rem",
-          fontSize: "0.8rem",
-          color: "var(--text-secondary)",
-          textAlign: "center",
-          fontStyle: "italic",
-        }}
-      >
-        <span>
-          {branch} now at {commitHash}
-          {subject && ` "${subject}"`}
-          {canShowDiff && (
-            <>
-              {" "}
-              <a
-                href="#"
-                onClick={(e) => {
-                  e.preventDefault();
-                  handleDiffClick();
-                }}
-                style={{
-                  color: "var(--link-color, #0066cc)",
-                  textDecoration: "underline",
-                }}
-              >
-                diff
-              </a>
-            </>
-          )}
-        </span>
-      </div>
-    );
+function Message({ message, onOpenDiffViewer, onCommentTextChange }: MessageProps) {
+  // Hide system messages from the UI
+  if (message.type === "system") {
+    return null;
+  }
+
+  // Render gitinfo messages as compact status updates
+  if (message.type === "gitinfo") {
+    return <GitInfoMessage message={message} onOpenDiffViewer={onOpenDiffViewer} />;
   }
 
   // Action bar state (show on hover or tap)