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