From 40cb92f6a2f4189db87acfe44703dac7215a7076 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sun, 16 Aug 2026 02:08:30 -0600 Subject: [PATCH] Split pins-panel.tsx into per-concern pin modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pins-panel.tsx had grown to 973 lines — the largest component in apps/web — holding everything from URL/PR display helpers to the collapsible group machinery to the panel shell itself. Move each concern into its own file, whole and unchanged, so no hook changes owner and the rendered output is identical: - pin-value-utils.ts pure display helpers (formatPrDisplay, trimFilenameForDisplay, normalizeExternalHref, resolveDisplayValue, markdown plain-text check) - pin-value-row.tsx CopyButton, MarkdownPinBody, PinCaption, PinValueRow — the leaf value primitives - pin-shortcut-item.tsx ShortcutPinItem and DISABLED_PIN_REASON - pin-item.tsx PinItem, the per-pin dispatcher - pin-group.tsx layoutPins plus the group/collapse family - pins-panel.tsx PinList, ConfirmShortcutDialog, PinsPanel pins-panel.tsx drops to 233 lines; no new file exceeds 204. Imports form a DAG, so no module cycles. PinItem is no longer re-exported from pins-panel.tsx — nothing outside the cluster imported it. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/app/pin-group.tsx | 204 +++++ apps/web/src/components/app/pin-item.tsx | 86 ++ .../src/components/app/pin-shortcut-item.tsx | 169 ++++ apps/web/src/components/app/pin-value-row.tsx | 146 ++++ .../web/src/components/app/pin-value-utils.ts | 155 ++++ apps/web/src/components/app/pins-panel.tsx | 748 +----------------- 6 files changed, 764 insertions(+), 744 deletions(-) create mode 100644 apps/web/src/components/app/pin-group.tsx create mode 100644 apps/web/src/components/app/pin-item.tsx create mode 100644 apps/web/src/components/app/pin-shortcut-item.tsx create mode 100644 apps/web/src/components/app/pin-value-row.tsx create mode 100644 apps/web/src/components/app/pin-value-utils.ts diff --git a/apps/web/src/components/app/pin-group.tsx b/apps/web/src/components/app/pin-group.tsx new file mode 100644 index 00000000..910b01f3 --- /dev/null +++ b/apps/web/src/components/app/pin-group.tsx @@ -0,0 +1,204 @@ +import { ChevronRight } from "lucide-react"; +import { useAtom } from "jotai"; +import { useId, useState } from "react"; + +import { PinItem } from "@/components/app/pin-item"; +import { type AgentPin } from "@/components/app/types"; +import { pinGroupCollapsedAtomFamily } from "@/lib/store"; +import { cn } from "@/lib/utils"; + +/** + * Lays pins out into render order, collapsing every pin that shares a `group` + * name into a single block. The group is anchored where its *first* member + * sits, so a later member being re-pinned can never relocate the block. + */ +export type PinRow = + | { kind: "pin"; pin: AgentPin } + | { kind: "group"; name: string; pins: AgentPin[] }; + +export function layoutPins(pins: AgentPin[]): PinRow[] { + const rows: PinRow[] = []; + const groupRows = new Map>(); + + for (const pin of pins) { + const name = pin.group?.trim(); + if (!name) { + rows.push({ kind: "pin", pin }); + continue; + } + + const key = name.toLowerCase(); + const existing = groupRows.get(key); + if (existing) { + existing.pins.push(pin); + continue; + } + + const row = { kind: "group" as const, name, pins: [pin] }; + groupRows.set(key, row); + rows.push(row); + } + + return rows; +} + +/** + * Groups past this size start collapsed. A sidebar full of one agent's pins + * pushes every other group off screen, and a long group is exactly the case + * where the heading and count are more useful than the members. + */ +const AUTO_COLLAPSE_THRESHOLD = 8; + +type PinGroupProps = { + name: string; + pins: AgentPin[]; + collapseScope: string | null; + workspaceRoot: string | null; + agentIsRunning?: boolean; + onRunShortcut?: (pin: AgentPin, pointerType?: string) => void; + agentName?: string | null; + pendingPinId?: string | null; + buttonRef?: (pin: AgentPin, element: HTMLButtonElement | null) => void; +}; + +type PinGroupViewProps = Omit & { + collapsed: boolean; + onToggle: () => void; +}; + +function PinGroupView({ + name, + pins, + collapsed, + onToggle, + workspaceRoot, + agentIsRunning, + onRunShortcut, + agentName = null, + pendingPinId = null, + buttonRef, +}: PinGroupViewProps): JSX.Element { + // Ids must be unique per document, not per list: the desktop and mobile + // sidebars are both always mounted, so a name-derived id would appear twice + // and `aria-controls` would resolve to the other instance's region. + const uid = useId(); + const headingId = `pin-group-${uid}`; + const regionId = `pin-group-members-${uid}`; + + return ( +
+ + {/* The region stays in the tree so `aria-controls` always resolves and + `hidden` carries the state; its members unmount while collapsed. */} + +
+ ); +} + +/** An explicit choice always beats the size-based default. */ +function resolveCollapsed(choice: boolean | null, count: number): boolean { + return choice ?? count > AUTO_COLLAPSE_THRESHOLD; +} + +function PersistedPinGroup( + props: PinGroupProps & { collapseScope: string } +): JSX.Element { + const [choice, setChoice] = useAtom( + pinGroupCollapsedAtomFamily( + `${props.collapseScope}::${props.name.toLowerCase()}` + ) + ); + const collapsed = resolveCollapsed(choice, props.pins.length); + return ( + setChoice(!collapsed)} + /> + ); +} + +function EphemeralPinGroup(props: PinGroupProps): JSX.Element { + const [choice, setChoice] = useState(null); + const collapsed = resolveCollapsed(choice, props.pins.length); + return ( + setChoice(!collapsed)} + /> + ); +} + +/** + * Branch above the hooks rather than calling both: with no scope there is + * nothing to namespace by, and persisting to a shared fallback key would leak + * one list's collapse choices onto every other unscoped list. `collapseScope` + * is stable per mount site, so this never swaps a component mid-life. + */ +export function PinGroup(props: PinGroupProps): JSX.Element { + return props.collapseScope === null ? ( + + ) : ( + + ); +} diff --git a/apps/web/src/components/app/pin-item.tsx b/apps/web/src/components/app/pin-item.tsx new file mode 100644 index 00000000..a7137856 --- /dev/null +++ b/apps/web/src/components/app/pin-item.tsx @@ -0,0 +1,86 @@ +import { ShortcutPinItem } from "@/components/app/pin-shortcut-item"; +import { + CopyButton, + PinCaption, + PinValueRow, +} from "@/components/app/pin-value-row"; +import { type AgentPin } from "@/components/app/types"; +import { splitPinValues } from "@/lib/pins"; +import { rewritePinUrl } from "@/lib/rewrite-pin-url"; +import { cn } from "@/lib/utils"; + +export function PinItem({ + pin, + workspaceRoot, + agentIsRunning = true, + onRunShortcut, + inGroup = false, + agentName = null, + pendingPinId = null, + buttonRef, +}: { + pin: AgentPin; + workspaceRoot: string | null; + agentIsRunning?: boolean; + onRunShortcut?: (pin: AgentPin, pointerType?: string) => void; + inGroup?: boolean; + agentName?: string | null; + pendingPinId?: string | null; + buttonRef?: (pin: AgentPin, element: HTMLButtonElement | null) => void; +}): JSX.Element { + if (pin.type === "shortcut") { + return ( + onRunShortcut?.(pin, pointerType)} + inGroup={inGroup} + agentName={agentName} + buttonRef={buttonRef} + /> + ); + } + + const effectiveValue = + pin.type === "url" + ? rewritePinUrl(pin.value, window.location.host) + : pin.value; + const values = splitPinValues(pin.type, effectiveValue); + const isMulti = values.length > 1; + + return ( +
+
+
+ {pin.label} +
+
+ +
+
+
+ {values.map((v, i) => ( + + ))} +
+ {pin.caption ? : null} +
+ ); +} diff --git a/apps/web/src/components/app/pin-shortcut-item.tsx b/apps/web/src/components/app/pin-shortcut-item.tsx new file mode 100644 index 00000000..e15ccd45 --- /dev/null +++ b/apps/web/src/components/app/pin-shortcut-item.tsx @@ -0,0 +1,169 @@ +import { AlertTriangle, Ban, CornerDownLeft, Loader2 } from "lucide-react"; + +import { PinCaption } from "@/components/app/pin-value-row"; +import { type AgentPin } from "@/components/app/types"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useCoarsePointer } from "@/hooks/use-coarse-pointer"; +import { resolvePinShortcutIcon } from "@/lib/pin-shortcut-icons"; +import { cn } from "@/lib/utils"; + +/** + * Shown both as the tooltip explanation and — when the agent didn't supply a + * caption — as the caption fallback for a pin it explicitly disabled. Kept as + * one constant so the two surfaces can't drift apart. + */ +const DISABLED_PIN_REASON = "This action is currently unavailable."; + +export function ShortcutPinItem({ + pin, + agentUnavailable, + pending, + onRun, + inGroup, + agentName, + buttonRef, +}: { + pin: AgentPin; + agentUnavailable: boolean; + pending: boolean; + onRun: (pointerType: string) => void; + inGroup: boolean; + agentName: string | null; + buttonRef?: (pin: AgentPin, element: HTMLButtonElement | null) => void; +}): JSX.Element { + const coarsePointer = useCoarsePointer(); + // A destructive shortcut's colour is its only pre-click warning, and some + // themes render primary and destructive almost identically — so carry the + // warning in the glyph too, which no palette can wash out. An agent-disabled + // pin outranks both: it's a deliberate, semi-durable state (not a passing + // "agent isn't running yet"), and needs to read differently at a glance + // from the other blocked states below, which share the same dimmed styling. + const Icon = pin.disabled + ? Ban + : pin.variant === "destructive" + ? AlertTriangle + : resolvePinShortcutIcon(pin.icon); + // No ID means the run endpoint has nothing to address; render it inert + // rather than as a button that silently does nothing on click. An + // agent-set `disabled` is a third, independent reason a shortcut can't + // fire — checked here rather than folded into `agentUnavailable` so its + // tooltip copy stays distinct from "agent not running". + const unavailable = agentUnavailable || !pin.id || Boolean(pin.disabled); + const blocked = unavailable || pending; + const disabledReason = !pin.id + ? "This pin has no stable ID, so it cannot be run." + : pin.disabled + ? DISABLED_PIN_REASON + : `${agentName ?? "This agent"} has no active session — shortcuts are unavailable.`; + // The tooltip needs a hover, which touch devices can't reach — so a + // disabled pin with no caption of its own falls back to the same reason + // text there, and a stale pre-disable caption never masks it. + const captionValue = + pin.caption ?? (pin.disabled ? DISABLED_PIN_REASON : undefined); + + return ( +
+ + + + + + + {unavailable ? ( +

+ {disabledReason} +

+ ) : ( +
+
+ {/* The label is truncated in the button, so the tooltip is + the only place it can be read in full. */} +
+ {pin.label} +
+
+ {agentName ?? "this agent"} will receive the following: +
+
+ {/* The prompt reads as a quoted payload, not prose — same + monospace treatment the terminal will show it in. */} +
+                  {pin.value}
+                
+ {pin.value.length > 400 ? ( +
+ Scroll for the full prompt ({pin.value.length} characters). +
+ ) : null} +
+ )} +
+
+
+ {captionValue ? : null} +
+ ); +} diff --git a/apps/web/src/components/app/pin-value-row.tsx b/apps/web/src/components/app/pin-value-row.tsx new file mode 100644 index 00000000..107837ff --- /dev/null +++ b/apps/web/src/components/app/pin-value-row.tsx @@ -0,0 +1,146 @@ +import { Check, Copy, FileText, GitPullRequest } from "lucide-react"; + +import { FrontTruncatedValue } from "@/components/app/agent-meta"; +import { + resolveDisplayValue, + shouldRenderMarkdownAsPlainText, + trimFilenameForDisplay, +} from "@/components/app/pin-value-utils"; +import { type AgentPin } from "@/components/app/types"; +import { Markdown } from "@/components/ui/markdown"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useCopyText } from "@/hooks/use-copy"; + +export function CopyButton({ + value, + title, +}: { + value: string; + title?: string; +}): JSX.Element { + const [copied, copyText] = useCopyText(); + + return ( + + ); +} + +function MarkdownPinBody({ value }: { value: string }): JSX.Element { + const renderAsPlainText = shouldRenderMarkdownAsPlainText(value); + + return ( + +
+ {renderAsPlainText ? ( +
+            {value}
+          
+ ) : ( + {value} + )} +
+
+ ); +} + +/** + * Shortcut pins are a button, not a value: `label` is the button text, `value` + * is the prompt delivered to the owning agent on click, and `caption` is an + * optional one-line caption for context a human wants before clicking. On a + * `disabled` shortcut the same slot doubles as the reason it's unavailable + * (e.g. "already building — agt_...") — there's no separate reason field. + */ +export function PinCaption({ value }: { value: string }): JSX.Element { + return ( +
+ {value} +
+ ); +} + +export function PinValueRow({ + type, + value, + workspaceRoot, +}: { + type: AgentPin["type"]; + value: string; + workspaceRoot: string | null; +}): JSX.Element { + if (type === "markdown") { + return ; + } + + const filenameValue = + type === "filename" ? trimFilenameForDisplay(value, workspaceRoot) : null; + const { display, tooltip, href, badge, icon } = resolveDisplayValue( + type, + filenameValue?.display ?? value + ); + const tooltipValue = filenameValue?.tooltip ?? tooltip; + + return ( +
+ {icon === "pr" && ( + + )} + {icon === "file" && ( + + )} + {href ? ( + + {display} + + ) : badge ? ( + type === "filename" ? ( + + ) : ( + + {display} + + ) + ) : ( + + {type === "string" ? ( +
+              {display}
+            
+ ) : ( + + {display} + + )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/app/pin-value-utils.ts b/apps/web/src/components/app/pin-value-utils.ts new file mode 100644 index 00000000..88370ad6 --- /dev/null +++ b/apps/web/src/components/app/pin-value-utils.ts @@ -0,0 +1,155 @@ +import { type AgentPin } from "@/components/app/types"; + +const SAFE_URL_RE = /^https?:\/\//i; +const GH_PR_RE = /^https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/i; + +/** Turn a GitHub PR URL into "owner/repo#123"; fall back to the raw value. */ +export function formatPrDisplay(value: string): string { + const m = GH_PR_RE.exec(value); + return m ? `${m[1]}#${m[2]}` : value; +} + +export type ResolvedValue = { + display: string; + tooltip: string; + href: string | null; + badge: boolean; + icon: "pr" | "file" | null; +}; + +export function trimFilenameForDisplay( + value: string, + workspaceRoot: string | null +): { display: string; tooltip: string } { + if (!workspaceRoot) { + return { display: value, tooltip: value }; + } + + const normalizedRoot = workspaceRoot.endsWith("/") + ? workspaceRoot.slice(0, -1) + : workspaceRoot; + if (!normalizedRoot) { + return { display: value, tooltip: value }; + } + + if (value === normalizedRoot) { + return { display: "./", tooltip: value }; + } + + const prefix = `${normalizedRoot}/`; + return value.startsWith(prefix) + ? { display: value.slice(prefix.length), tooltip: value } + : { display: value, tooltip: value }; +} + +export function shouldRenderMarkdownAsPlainText(value: string): boolean { + const sanitized = value.replace(/```[^\n]*\n[\s\S]*?```/g, ""); + const unsupportedPatterns = [ + /!\[[^\]]*]\((?:[^()\\]|\\.)+\)/, + /\[[^\]]+]\((?:[^()\\]|\\.)+\)/, + /\[[^\]]+]\[[^\]]*]/, + /^\s*\[[^\]]+]:\s*\S+/m, + /<\/?[A-Za-z][^>]*>/, + /^\s{0,3}#{1,6}\s/m, + /^\s{0,3}>\s/m, + /^\s{0,3}\d+\.\s/m, + /^(?: {2,}|\t+)[-*+]\s/m, + /^(?: {2,}|\t+)\d+\.\s/m, + ]; + return unsupportedPatterns.some((pattern) => pattern.test(sanitized)); +} + +export function normalizeExternalHref( + type: AgentPin["type"], + value: string +): string | null { + if (type !== "url" && type !== "pr") return null; + + const trimmed = value.trim(); + if (!trimmed) return null; + + const candidate = SAFE_URL_RE.test(trimmed) + ? trimmed + : type === "url" + ? `http://${trimmed}` + : trimmed; + + try { + const parsed = new URL(candidate); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return parsed.toString(); + } + } catch { + return null; + } + + return null; +} + +export function resolveDisplayValue( + type: AgentPin["type"], + value: string +): ResolvedValue { + const href = normalizeExternalHref(type, value); + if (type === "pr" && href) { + return { + display: formatPrDisplay(href), + tooltip: value.trim(), + href, + badge: false, + icon: "pr", + }; + } + if (type === "pr") { + return { + display: value, + tooltip: value, + href: null, + badge: false, + icon: "pr", + }; + } + if (type === "url" && href) { + return { + display: value.trim(), + tooltip: value.trim(), + href, + badge: false, + icon: null, + }; + } + if (type === "url") { + return { + display: value, + tooltip: value, + href: null, + badge: false, + icon: null, + }; + } + if (type === "filename") { + return { + display: value, + tooltip: value, + href: null, + badge: true, + icon: "file", + }; + } + if (type === "port" || type === "code") { + return { + display: value, + tooltip: value, + href: null, + badge: true, + icon: null, + }; + } + return { + display: value, + tooltip: value, + href: null, + badge: false, + icon: null, + }; +} diff --git a/apps/web/src/components/app/pins-panel.tsx b/apps/web/src/components/app/pins-panel.tsx index 38569bfa..5f6fa624 100644 --- a/apps/web/src/components/app/pins-panel.tsx +++ b/apps/web/src/components/app/pins-panel.tsx @@ -1,23 +1,10 @@ -import { - AlertTriangle, - Ban, - Check, - ChevronRight, - CornerDownLeft, - Copy, - FileText, - GitPullRequest, - Loader2, - Pin, -} from "lucide-react"; -import { useAtom } from "jotai"; -import { useId, useRef, useState } from "react"; +import { Pin } from "lucide-react"; +import { useRef, useState } from "react"; -import { FrontTruncatedValue } from "@/components/app/agent-meta"; +import { PinGroup, layoutPins } from "@/components/app/pin-group"; +import { PinItem } from "@/components/app/pin-item"; import { type AgentPin } from "@/components/app/types"; import { Button } from "@/components/ui/button"; -import { resolvePinShortcutIcon } from "@/lib/pin-shortcut-icons"; -import { cn } from "@/lib/utils"; import { Dialog, DialogContent, @@ -25,734 +12,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Markdown } from "@/components/ui/markdown"; import { useCoarsePointer } from "@/hooks/use-coarse-pointer"; -import { useCopyText } from "@/hooks/use-copy"; -import { splitPinValues } from "@/lib/pins"; -import { pinGroupCollapsedAtomFamily } from "@/lib/store"; -import { rewritePinUrl } from "@/lib/rewrite-pin-url"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; - -const SAFE_URL_RE = /^https?:\/\//i; -const GH_PR_RE = /^https?:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/i; - -/** Turn a GitHub PR URL into "owner/repo#123"; fall back to the raw value. */ -function formatPrDisplay(value: string): string { - const m = GH_PR_RE.exec(value); - return m ? `${m[1]}#${m[2]}` : value; -} - -function CopyButton({ - value, - title, -}: { - value: string; - title?: string; -}): JSX.Element { - const [copied, copyText] = useCopyText(); - - return ( - - ); -} - -type ResolvedValue = { - display: string; - tooltip: string; - href: string | null; - badge: boolean; - icon: "pr" | "file" | null; -}; - -function trimFilenameForDisplay( - value: string, - workspaceRoot: string | null -): { display: string; tooltip: string } { - if (!workspaceRoot) { - return { display: value, tooltip: value }; - } - - const normalizedRoot = workspaceRoot.endsWith("/") - ? workspaceRoot.slice(0, -1) - : workspaceRoot; - if (!normalizedRoot) { - return { display: value, tooltip: value }; - } - - if (value === normalizedRoot) { - return { display: "./", tooltip: value }; - } - - const prefix = `${normalizedRoot}/`; - return value.startsWith(prefix) - ? { display: value.slice(prefix.length), tooltip: value } - : { display: value, tooltip: value }; -} - -function shouldRenderMarkdownAsPlainText(value: string): boolean { - const sanitized = value.replace(/```[^\n]*\n[\s\S]*?```/g, ""); - const unsupportedPatterns = [ - /!\[[^\]]*]\((?:[^()\\]|\\.)+\)/, - /\[[^\]]+]\((?:[^()\\]|\\.)+\)/, - /\[[^\]]+]\[[^\]]*]/, - /^\s*\[[^\]]+]:\s*\S+/m, - /<\/?[A-Za-z][^>]*>/, - /^\s{0,3}#{1,6}\s/m, - /^\s{0,3}>\s/m, - /^\s{0,3}\d+\.\s/m, - /^(?: {2,}|\t+)[-*+]\s/m, - /^(?: {2,}|\t+)\d+\.\s/m, - ]; - return unsupportedPatterns.some((pattern) => pattern.test(sanitized)); -} - -function normalizeExternalHref( - type: AgentPin["type"], - value: string -): string | null { - if (type !== "url" && type !== "pr") return null; - - const trimmed = value.trim(); - if (!trimmed) return null; - - const candidate = SAFE_URL_RE.test(trimmed) - ? trimmed - : type === "url" - ? `http://${trimmed}` - : trimmed; - - try { - const parsed = new URL(candidate); - if (parsed.protocol === "http:" || parsed.protocol === "https:") { - return parsed.toString(); - } - } catch { - return null; - } - - return null; -} - -function MarkdownPinBody({ value }: { value: string }): JSX.Element { - const renderAsPlainText = shouldRenderMarkdownAsPlainText(value); - - return ( - -
- {renderAsPlainText ? ( -
-            {value}
-          
- ) : ( - {value} - )} -
-
- ); -} - -function resolveDisplayValue( - type: AgentPin["type"], - value: string -): ResolvedValue { - const href = normalizeExternalHref(type, value); - if (type === "pr" && href) { - return { - display: formatPrDisplay(href), - tooltip: value.trim(), - href, - badge: false, - icon: "pr", - }; - } - if (type === "pr") { - return { - display: value, - tooltip: value, - href: null, - badge: false, - icon: "pr", - }; - } - if (type === "url" && href) { - return { - display: value.trim(), - tooltip: value.trim(), - href, - badge: false, - icon: null, - }; - } - if (type === "url") { - return { - display: value, - tooltip: value, - href: null, - badge: false, - icon: null, - }; - } - if (type === "filename") { - return { - display: value, - tooltip: value, - href: null, - badge: true, - icon: "file", - }; - } - if (type === "port" || type === "code") { - return { - display: value, - tooltip: value, - href: null, - badge: true, - icon: null, - }; - } - return { - display: value, - tooltip: value, - href: null, - badge: false, - icon: null, - }; -} - -function PinValueRow({ - type, - value, - workspaceRoot, -}: { - type: AgentPin["type"]; - value: string; - workspaceRoot: string | null; -}): JSX.Element { - if (type === "markdown") { - return ; - } - - const filenameValue = - type === "filename" ? trimFilenameForDisplay(value, workspaceRoot) : null; - const { display, tooltip, href, badge, icon } = resolveDisplayValue( - type, - filenameValue?.display ?? value - ); - const tooltipValue = filenameValue?.tooltip ?? tooltip; - - return ( -
- {icon === "pr" && ( - - )} - {icon === "file" && ( - - )} - {href ? ( - - {display} - - ) : badge ? ( - type === "filename" ? ( - - ) : ( - - {display} - - ) - ) : ( - - {type === "string" ? ( -
-              {display}
-            
- ) : ( - - {display} - - )} -
- )} -
- ); -} - -/** - * Shortcut pins are a button, not a value: `label` is the button text, `value` - * is the prompt delivered to the owning agent on click, and `caption` is an - * optional one-line caption for context a human wants before clicking. On a - * `disabled` shortcut the same slot doubles as the reason it's unavailable - * (e.g. "already building — agt_...") — there's no separate reason field. - */ -function PinCaption({ value }: { value: string }): JSX.Element { - return ( -
- {value} -
- ); -} - -/** - * Shown both as the tooltip explanation and — when the agent didn't supply a - * caption — as the caption fallback for a pin it explicitly disabled. Kept as - * one constant so the two surfaces can't drift apart. - */ -const DISABLED_PIN_REASON = "This action is currently unavailable."; - -function ShortcutPinItem({ - pin, - agentUnavailable, - pending, - onRun, - inGroup, - agentName, - buttonRef, -}: { - pin: AgentPin; - agentUnavailable: boolean; - pending: boolean; - onRun: (pointerType: string) => void; - inGroup: boolean; - agentName: string | null; - buttonRef?: (pin: AgentPin, element: HTMLButtonElement | null) => void; -}): JSX.Element { - const coarsePointer = useCoarsePointer(); - // A destructive shortcut's colour is its only pre-click warning, and some - // themes render primary and destructive almost identically — so carry the - // warning in the glyph too, which no palette can wash out. An agent-disabled - // pin outranks both: it's a deliberate, semi-durable state (not a passing - // "agent isn't running yet"), and needs to read differently at a glance - // from the other blocked states below, which share the same dimmed styling. - const Icon = pin.disabled - ? Ban - : pin.variant === "destructive" - ? AlertTriangle - : resolvePinShortcutIcon(pin.icon); - // No ID means the run endpoint has nothing to address; render it inert - // rather than as a button that silently does nothing on click. An - // agent-set `disabled` is a third, independent reason a shortcut can't - // fire — checked here rather than folded into `agentUnavailable` so its - // tooltip copy stays distinct from "agent not running". - const unavailable = agentUnavailable || !pin.id || Boolean(pin.disabled); - const blocked = unavailable || pending; - const disabledReason = !pin.id - ? "This pin has no stable ID, so it cannot be run." - : pin.disabled - ? DISABLED_PIN_REASON - : `${agentName ?? "This agent"} has no active session — shortcuts are unavailable.`; - // The tooltip needs a hover, which touch devices can't reach — so a - // disabled pin with no caption of its own falls back to the same reason - // text there, and a stale pre-disable caption never masks it. - const captionValue = - pin.caption ?? (pin.disabled ? DISABLED_PIN_REASON : undefined); - - return ( -
- - - - - - - {unavailable ? ( -

- {disabledReason} -

- ) : ( -
-
- {/* The label is truncated in the button, so the tooltip is - the only place it can be read in full. */} -
- {pin.label} -
-
- {agentName ?? "this agent"} will receive the following: -
-
- {/* The prompt reads as a quoted payload, not prose — same - monospace treatment the terminal will show it in. */} -
-                  {pin.value}
-                
- {pin.value.length > 400 ? ( -
- Scroll for the full prompt ({pin.value.length} characters). -
- ) : null} -
- )} -
-
-
- {captionValue ? : null} -
- ); -} - -export function PinItem({ - pin, - workspaceRoot, - agentIsRunning = true, - onRunShortcut, - inGroup = false, - agentName = null, - pendingPinId = null, - buttonRef, -}: { - pin: AgentPin; - workspaceRoot: string | null; - agentIsRunning?: boolean; - onRunShortcut?: (pin: AgentPin, pointerType?: string) => void; - inGroup?: boolean; - agentName?: string | null; - pendingPinId?: string | null; - buttonRef?: (pin: AgentPin, element: HTMLButtonElement | null) => void; -}): JSX.Element { - if (pin.type === "shortcut") { - return ( - onRunShortcut?.(pin, pointerType)} - inGroup={inGroup} - agentName={agentName} - buttonRef={buttonRef} - /> - ); - } - - const effectiveValue = - pin.type === "url" - ? rewritePinUrl(pin.value, window.location.host) - : pin.value; - const values = splitPinValues(pin.type, effectiveValue); - const isMulti = values.length > 1; - - return ( -
-
-
- {pin.label} -
-
- -
-
-
- {values.map((v, i) => ( - - ))} -
- {pin.caption ? : null} -
- ); -} - -/** - * Lays pins out into render order, collapsing every pin that shares a `group` - * name into a single block. The group is anchored where its *first* member - * sits, so a later member being re-pinned can never relocate the block. - */ -type PinRow = - | { kind: "pin"; pin: AgentPin } - | { kind: "group"; name: string; pins: AgentPin[] }; - -function layoutPins(pins: AgentPin[]): PinRow[] { - const rows: PinRow[] = []; - const groupRows = new Map>(); - - for (const pin of pins) { - const name = pin.group?.trim(); - if (!name) { - rows.push({ kind: "pin", pin }); - continue; - } - - const key = name.toLowerCase(); - const existing = groupRows.get(key); - if (existing) { - existing.pins.push(pin); - continue; - } - - const row = { kind: "group" as const, name, pins: [pin] }; - groupRows.set(key, row); - rows.push(row); - } - - return rows; -} - -/** - * Groups past this size start collapsed. A sidebar full of one agent's pins - * pushes every other group off screen, and a long group is exactly the case - * where the heading and count are more useful than the members. - */ -const AUTO_COLLAPSE_THRESHOLD = 8; - -type PinGroupProps = { - name: string; - pins: AgentPin[]; - collapseScope: string | null; - workspaceRoot: string | null; - agentIsRunning?: boolean; - onRunShortcut?: (pin: AgentPin, pointerType?: string) => void; - agentName?: string | null; - pendingPinId?: string | null; - buttonRef?: (pin: AgentPin, element: HTMLButtonElement | null) => void; -}; - -type PinGroupViewProps = Omit & { - collapsed: boolean; - onToggle: () => void; -}; - -function PinGroupView({ - name, - pins, - collapsed, - onToggle, - workspaceRoot, - agentIsRunning, - onRunShortcut, - agentName = null, - pendingPinId = null, - buttonRef, -}: PinGroupViewProps): JSX.Element { - // Ids must be unique per document, not per list: the desktop and mobile - // sidebars are both always mounted, so a name-derived id would appear twice - // and `aria-controls` would resolve to the other instance's region. - const uid = useId(); - const headingId = `pin-group-${uid}`; - const regionId = `pin-group-members-${uid}`; - - return ( -
- - {/* The region stays in the tree so `aria-controls` always resolves and - `hidden` carries the state; its members unmount while collapsed. */} - -
- ); -} - -/** An explicit choice always beats the size-based default. */ -function resolveCollapsed(choice: boolean | null, count: number): boolean { - return choice ?? count > AUTO_COLLAPSE_THRESHOLD; -} - -function PersistedPinGroup( - props: PinGroupProps & { collapseScope: string } -): JSX.Element { - const [choice, setChoice] = useAtom( - pinGroupCollapsedAtomFamily( - `${props.collapseScope}::${props.name.toLowerCase()}` - ) - ); - const collapsed = resolveCollapsed(choice, props.pins.length); - return ( - setChoice(!collapsed)} - /> - ); -} - -function EphemeralPinGroup(props: PinGroupProps): JSX.Element { - const [choice, setChoice] = useState(null); - const collapsed = resolveCollapsed(choice, props.pins.length); - return ( - setChoice(!collapsed)} - /> - ); -} - -/** - * Branch above the hooks rather than calling both: with no scope there is - * nothing to namespace by, and persisting to a shared fallback key would leak - * one list's collapse choices onto every other unscoped list. `collapseScope` - * is stable per mount site, so this never swaps a component mid-life. - */ -function PinGroup(props: PinGroupProps): JSX.Element { - return props.collapseScope === null ? ( - - ) : ( - - ); -} /** * The rendering unit for a set of pins: grouping policy and `PinItem` travel