@talgent/workbench-ui 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +15 -2
  2. package/assets/brand-icons/LICENSE.md +30 -0
  3. package/assets/brand-icons/README.md +5 -0
  4. package/assets/brand-icons/figma.svg +1 -0
  5. package/assets/brand-icons/github.svg +1 -0
  6. package/assets/brand-icons/linear.svg +1 -0
  7. package/assets/brand-icons/notion.svg +1 -0
  8. package/assets/brand-icons/sentry.svg +1 -0
  9. package/assets/brand-icons/slack.svg +1 -0
  10. package/assets/fonts/Inter-OFL.txt +93 -0
  11. package/assets/fonts/inter-latin.woff2 +0 -0
  12. package/components/agent/agent-icon.css +14 -0
  13. package/components/agent/agent-icon.tsx +14 -0
  14. package/components/analytics/chart-geometry.ts +95 -0
  15. package/components/analytics/dashboard-charts.tsx +136 -0
  16. package/components/analytics/model-usage-chart.tsx +84 -0
  17. package/components/analytics/token-breakdown.css +13 -0
  18. package/components/analytics/token-breakdown.tsx +23 -0
  19. package/components/analytics/token-report.tsx +57 -0
  20. package/components/analytics/work-usage/turn-timeline.tsx +98 -0
  21. package/components/analytics/work-usage/turns.ts +170 -0
  22. package/components/analytics/work-usage/work-status.ts +36 -0
  23. package/components/analytics/work-usage/work-usage-reader.tsx +122 -0
  24. package/components/analytics/work-usage/work-usage.css +48 -0
  25. package/components/auth/auth-gate.tsx +44 -0
  26. package/components/chat/message-input.tsx +105 -0
  27. package/components/composer/permission-mode-list.tsx +15 -0
  28. package/components/composer/prompt-bar-controls.tsx +77 -0
  29. package/components/composer/prompt-bar.tsx +45 -0
  30. package/components/composer/skill-completion.css +27 -0
  31. package/components/composer/skill-completion.tsx +49 -0
  32. package/components/composer/use-slash-completion.tsx +69 -0
  33. package/components/composer/work-start.css +133 -0
  34. package/components/feedback/toast-region.css +2 -0
  35. package/components/feedback/toast-region.tsx +15 -0
  36. package/components/files/file-facts.tsx +16 -0
  37. package/components/files/file-section.tsx +181 -0
  38. package/components/markdown.tsx +144 -0
  39. package/components/marketplace/connector-pages.tsx +166 -0
  40. package/components/marketplace/marketplace-icons.tsx +66 -0
  41. package/components/marketplace/marketplace-shared.tsx +43 -0
  42. package/components/marketplace/marketplace-shell.tsx +32 -0
  43. package/components/marketplace/marketplace.css +97 -0
  44. package/components/marketplace/parameter-fields.tsx +46 -0
  45. package/components/marketplace/plugin-list.tsx +84 -0
  46. package/components/marketplace/plugin-page.tsx +133 -0
  47. package/components/marketplace/provider-pages.tsx +120 -0
  48. package/components/preview/file-format.ts +75 -0
  49. package/components/preview/file-preview.tsx +153 -0
  50. package/components/preview/preview-verdict.ts +44 -0
  51. package/components/preview/preview.css +19 -0
  52. package/components/preview/text-preview.tsx +174 -0
  53. package/components/profile/provider-callback.tsx +43 -0
  54. package/components/profile/work-agent-appearance.tsx +109 -0
  55. package/components/profile/work-agent-soul.tsx +76 -0
  56. package/components/rail/rail-section.tsx +20 -0
  57. package/components/rail/rail.css +19 -0
  58. package/components/rail/use-expand-on-change.ts +18 -0
  59. package/components/session/startup-progress.css +77 -0
  60. package/components/session/startup-progress.tsx +103 -0
  61. package/components/session/work-permission-control.tsx +45 -0
  62. package/components/session/work-thinking-control.tsx +52 -0
  63. package/components/shell/account-menu.tsx +134 -0
  64. package/components/shell/app-shell.tsx +148 -0
  65. package/components/shell/core-reading-views.tsx +26 -0
  66. package/components/shell/core-reading.tsx +17 -0
  67. package/components/shell/document-title.tsx +11 -0
  68. package/components/shell/entry-shell.ts +39 -0
  69. package/components/shell/file-reading.tsx +22 -0
  70. package/components/shell/page-layout.tsx +7 -0
  71. package/components/shell/reading-context.ts +41 -0
  72. package/components/shell/reading-panel.css +67 -0
  73. package/components/shell/reading-panel.tsx +74 -0
  74. package/components/shell/sidebar-parts.tsx +38 -0
  75. package/components/work/context-rail.css +61 -0
  76. package/components/work/conversation-unavailable.css +6 -0
  77. package/components/work/conversation-unavailable.tsx +11 -0
  78. package/components/work/subagent-item.tsx +42 -0
  79. package/components/work/subagents-section.css +31 -0
  80. package/components/work/subagents-section.tsx +40 -0
  81. package/components/work/token-usage.tsx +28 -0
  82. package/components/work/tool-message-detail.tsx +39 -0
  83. package/components/work/tool-output-preview.tsx +52 -0
  84. package/components/work/tool-preview-reading.tsx +27 -0
  85. package/components/work/tool-reading-content.tsx +31 -0
  86. package/components/work/work-context-rail.tsx +23 -0
  87. package/components/work/work-conversation.tsx +207 -0
  88. package/components/work/work-history.tsx +115 -0
  89. package/components/work/work-interaction.css +38 -0
  90. package/components/work/work-interaction.tsx +105 -0
  91. package/components/work/work-message-flow.css +86 -0
  92. package/components/work/work-message-flow.tsx +352 -0
  93. package/components/work/work-progress-section.tsx +20 -0
  94. package/components/work/work-prompt-bar.tsx +82 -0
  95. package/components/work/work-question-record.tsx +72 -0
  96. package/components/work/work-snapshot-state.tsx +24 -0
  97. package/components/work/work-status-control.tsx +54 -0
  98. package/components/work-start/start-work-launch-state.ts +33 -0
  99. package/components/work-start/work-start-catalog.tsx +116 -0
  100. package/components/work-start/work-start-form.tsx +173 -0
  101. package/components/work-start/work-start-tray.css +40 -0
  102. package/components/work-start/work-start-tray.tsx +128 -0
  103. package/hooks/use-debounced.ts +11 -0
  104. package/hooks/use-work-conversation.ts +125 -0
  105. package/hooks/use-work-lifecycle-controls.ts +40 -0
  106. package/hooks/use-work-stream.ts +91 -0
  107. package/lib/account-service.ts +77 -0
  108. package/lib/agent-appearance-service.ts +59 -0
  109. package/lib/agent-icon.ts +26 -0
  110. package/lib/agent-soul-service.ts +48 -0
  111. package/lib/agent-work-service.ts +23 -0
  112. package/lib/client.ts +20 -0
  113. package/lib/computer-release.ts +30 -0
  114. package/lib/computer-service.ts +241 -0
  115. package/lib/dashboard-report.ts +38 -0
  116. package/lib/dashboard.ts +21 -0
  117. package/lib/marketplace-catalog.ts +138 -0
  118. package/lib/marketplace-connections.ts +98 -0
  119. package/lib/marketplace-plugins.ts +53 -0
  120. package/lib/marketplace-routes.ts +12 -0
  121. package/lib/model-vendors.ts +13 -0
  122. package/lib/name-taken-error.ts +7 -0
  123. package/lib/notifications.ts +37 -0
  124. package/lib/permission-modes.ts +25 -0
  125. package/lib/personal-usage-service.ts +19 -0
  126. package/lib/personal-work-service.ts +159 -0
  127. package/lib/plugin-parameters.ts +64 -0
  128. package/lib/preview-verdict.ts +26 -0
  129. package/lib/relative-time.ts +11 -0
  130. package/lib/session.ts +108 -0
  131. package/lib/share-link.ts +4 -0
  132. package/lib/stale-build-reload.ts +41 -0
  133. package/lib/start-work-request.ts +34 -0
  134. package/lib/startup-progress.ts +9 -0
  135. package/lib/subagents.ts +12 -0
  136. package/lib/thinking-levels.ts +2 -0
  137. package/lib/token-analytics.ts +168 -0
  138. package/lib/token-usage.ts +64 -0
  139. package/lib/trace-view-model.ts +292 -0
  140. package/lib/transport.ts +29 -0
  141. package/lib/use-now.ts +11 -0
  142. package/lib/use-work-trace.ts +175 -0
  143. package/lib/user-display.ts +49 -0
  144. package/lib/work-conversation.ts +118 -0
  145. package/lib/work-file-service.ts +118 -0
  146. package/lib/work-model.ts +38 -0
  147. package/lib/work-notices.ts +23 -0
  148. package/lib/work-progress.ts +41 -0
  149. package/lib/work-projection.ts +122 -0
  150. package/lib/work-questions.ts +18 -0
  151. package/lib/work-start-draft.ts +61 -0
  152. package/lib/work-start-loadout.ts +100 -0
  153. package/lib/work-start-source.ts +16 -0
  154. package/lib/work-state-presentation.ts +80 -0
  155. package/lib/work-stream-source.ts +32 -0
  156. package/package.json +87 -3
  157. package/pages/computers/computers-page.tsx +56 -0
  158. package/pages/computers/connect-computer-page.tsx +92 -0
  159. package/pages/computers/install-commands.tsx +42 -0
  160. package/pages/computers/remove-computer-dialog.tsx +33 -0
  161. package/pages/computers/rename-computer-dialog.tsx +37 -0
  162. package/pages/computers/room-rules-dialog.tsx +55 -0
  163. package/pages/login-page.css +33 -0
  164. package/pages/login-page.tsx +88 -0
  165. package/pages/login-threads.tsx +51 -0
  166. package/pages/marketplace-page.tsx +59 -0
  167. package/pages/profile-page.tsx +68 -0
  168. package/pages/workbench/library-page.tsx +88 -0
  169. package/pages/workbench/new-work-page.tsx +24 -0
  170. package/pages/workbench/person.tsx +13 -0
  171. package/pages/workbench/personal-work-context.tsx +93 -0
  172. package/pages/workbench/personal-work-page.tsx +100 -0
  173. package/pages/workbench/personal-works-page.tsx +36 -0
  174. package/pages/workbench/share-dialog.tsx +57 -0
  175. package/pages/workbench/title-dialog.tsx +35 -0
  176. package/pages/workbench/usage-page.tsx +52 -0
  177. package/public/assets/brand/talgent-lettering.png +0 -0
  178. package/public/assets/brand/talgent-logo-page-bg.png +0 -0
  179. package/public/assets/brand/talgent-logo-white-bg.png +0 -0
  180. package/public/assets/brand/talgent-mark-page-bg.png +0 -0
  181. package/public/assets/brand/talgent-mark-white-bg.png +0 -0
  182. package/public/assets/brand/talgent-mark.png +0 -0
  183. package/public/assets/product-icons/LICENSE +21 -0
  184. package/public/assets/product-icons/README.md +13 -0
  185. package/public/assets/product-icons/anthropic.svg +1 -0
  186. package/public/assets/product-icons/deepseek.svg +1 -0
  187. package/public/assets/product-icons/glm.svg +1 -0
  188. package/public/assets/product-icons/kimi.svg +1 -0
  189. package/public/assets/product-icons/linear.svg +1 -0
  190. package/public/assets/product-icons/minimax.svg +1 -0
  191. package/public/assets/product-icons/notion.svg +1 -0
  192. package/public/assets/product-icons/openai.svg +1 -0
  193. package/public/assets/product-icons/qwen.svg +1 -0
  194. package/styles/base.css +95 -0
  195. package/styles/fonts.css +14 -0
  196. package/styles/markdown.css +79 -0
  197. package/styles/uber-theme.css +96 -0
  198. package/styles/work-composer.css +222 -0
  199. package/styles/work-stream.css +343 -0
  200. package/styles/workbench.css +144 -0
  201. package/styles/workspace.css +260 -0
  202. package/test-support/backend.ts +67 -0
  203. package/test-support/plugin-summary.ts +8 -0
  204. package/test-support/setup.ts +59 -0
  205. package/vite-preset.ts +85 -0
@@ -0,0 +1,105 @@
1
+ import { useEffect, useId, useRef, useState } from "react";
2
+ import { Alert, Button, Tooltip } from "@heroui/react";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { ArrowRight, ChevronLeft, ChevronRight, CornerDownLeft, MessageCircleQuestion, Pencil, Terminal, X } from "lucide-react";
5
+ import { MessageDetailFormat, UnifiedMessageKind, type PendingInteraction } from "@talgent/workbench-contracts/dispatcher/public/v1/unified_message_pb";
6
+ import { agentWorkClient } from "../../lib/client";
7
+ import { useWorkStreamSource } from "../../lib/work-stream-source";
8
+ import { parseWorkQuestions } from "../../lib/work-questions";
9
+ import { ToolMessageDetail } from "./tool-message-detail";
10
+ import "./work-interaction.css";
11
+
12
+ export function WorkInteraction({ workId, request, disabled = false, onResponded }: { workId: string; request: PendingInteraction; disabled?: boolean; onResponded: () => void }) {
13
+ const [pending, setPending] = useState(false);
14
+ const [sent, setSent] = useState(false);
15
+ const [error, setError] = useState("");
16
+ const inFlight = useRef(false);
17
+ const allowButton = useRef<HTMLButtonElement>(null);
18
+ useEffect(() => { if (!request.replySubmitted) allowButton.current?.focus({ preventScroll: true }); }, [request.replySubmitted]);
19
+ const locked = disabled || pending || sent || request.replySubmitted;
20
+ const respond = async (allow: boolean, alwaysAllow = false, updatedInputJson = "") => {
21
+ if (locked || inFlight.current) return;
22
+ inFlight.current = true; setPending(true); setError("");
23
+ try {
24
+ await agentWorkClient.respondToInteraction({ workId, interactionId: request.id, allow, alwaysAllow, updatedInputJson });
25
+ setSent(true); onResponded();
26
+ } catch (err) { setError(err instanceof Error ? err.message : "Response could not be sent. Try again."); }
27
+ finally { inFlight.current = false; setPending(false); }
28
+ };
29
+ const feedback = <>{error && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>}{(sent || request.replySubmitted) && <p className="wi-feedback" role="status">Response sent. Waiting for Work to continue…</p>}</>;
30
+ if (request.toolName === "AskUserQuestion") return <WorkQuestion request={request} disabled={locked} pending={pending} respond={respond} feedback={feedback} />;
31
+ return <section className="wi-card wi-approval" aria-label="Approval request" onKeyDown={event => { if (event.key === "Escape" && !locked) { event.preventDefault(); event.stopPropagation(); void respond(false); } }}>
32
+ <header className="wi-heading"><span><Terminal size={18} />{request.toolName === "Bash" ? "Terminal" : request.toolName}</span></header>
33
+ <h3>{request.prompt || `Allow ${request.toolName}?`}</h3>
34
+ {request.toolCallId && <ApprovalOperation workId={workId} toolCallId={request.toolCallId} />}
35
+ <footer className="wi-approval-footer">
36
+ <Button size="sm" variant="outline" isDisabled={locked} onPress={() => void respond(false)}>Deny <kbd>Esc</kbd></Button>
37
+ {request.canAlwaysAllow && <Tooltip><Button size="sm" variant="outline" isDisabled={locked} onPress={() => void respond(true, true)}>Always allow</Button><Tooltip.Content>Remember the matching permission rule</Tooltip.Content></Tooltip>}
38
+ <Button ref={allowButton} size="sm" isDisabled={locked} isPending={pending} onPress={() => void respond(true)}>Allow once <CornerDownLeft size={16} /></Button>
39
+ </footer>{feedback}
40
+ </section>;
41
+ }
42
+
43
+ function ApprovalOperation({ workId, toolCallId }: { workId: string; toolCallId: string }) {
44
+ const streamClient = useWorkStreamSource();
45
+ const [open, setOpen] = useState(false);
46
+ const operation = useQuery({
47
+ queryKey: ["approval-operation", workId, toolCallId],
48
+ queryFn: async ({ signal }) => (await streamClient.listMessagesBySession({ agentWorkId: workId, toolCallIds: [toolCallId], kinds: [UnifiedMessageKind.ACTION], limit: 1 }, { signal })).messages[0] ?? null,
49
+ gcTime: 0, retry: false, refetchOnWindowFocus: false,
50
+ refetchInterval: query => query.state.data || query.state.error ? false : 1000,
51
+ });
52
+ const message = operation.data;
53
+ return <>
54
+ {operation.isPending && <p className="sr-only" role="status">Loading operation…</p>}
55
+ {operation.isError && <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>Could not load the operation.</Alert.Description></Alert.Content><Button size="sm" variant="ghost" onPress={() => void operation.refetch()}>Retry</Button></Alert>}
56
+ {message?.payload.case === "action" && <pre className="wi-command">{message.payload.value.summary}</pre>}
57
+ {message && message.detailFormat !== MessageDetailFormat.UNSPECIFIED && <><Button size="sm" variant="ghost" aria-expanded={open} onPress={() => setOpen(!open)}>View full input</Button>{open && <ToolMessageDetail workId={workId} messageId={message.id} />}</>}
58
+ </>;
59
+ }
60
+
61
+ function WorkQuestion({ request, disabled, pending, respond, feedback }: {
62
+ request: PendingInteraction; disabled: boolean; pending: boolean;
63
+ respond: (allow: boolean, alwaysAllow: boolean, input: string) => Promise<void>; feedback: React.ReactNode;
64
+ }) {
65
+ const parsed = parseWorkQuestions(request.questionInputJson);
66
+ const [index, setIndex] = useState(0);
67
+ const [answers, setAnswers] = useState<Record<number, string[]>>({});
68
+ const [custom, setCustom] = useState<Record<number, string>>({});
69
+ const heading = useRef<HTMLHeadingElement>(null);
70
+ const titleId = useId();
71
+ useEffect(() => { heading.current?.focus({ preventScroll: true }); }, [index]);
72
+ if (!parsed) return <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Description>This question could not be displayed. You can cancel the current turn.</Alert.Description></Alert.Content></Alert>;
73
+ const { input, questions } = parsed;
74
+ const question = questions[index];
75
+ const value = (position: number) => custom[position]?.trim() ? custom[position] : (answers[position] ?? []).join(", ");
76
+ const answer = value(index);
77
+ const finish = (skipped?: number) => {
78
+ const result = Object.fromEntries(questions.flatMap((item, position) => position !== skipped && value(position) ? [[item.question, value(position)]] : []));
79
+ void respond(true, false, JSON.stringify({ ...input, answers: result }));
80
+ };
81
+ const next = (skip: boolean) => {
82
+ if (disabled) return;
83
+ if (index === questions.length - 1) { finish(skip ? index : undefined); return; }
84
+ if (skip) { setAnswers(values => ({ ...values, [index]: [] })); setCustom(values => ({ ...values, [index]: "" })); }
85
+ setIndex(index + 1);
86
+ };
87
+ return <section className="wi-card wi-question" aria-label="Answer questions">
88
+ <header className="wi-heading"><span><MessageCircleQuestion size={18} />Question</span><nav aria-label="Question navigation">
89
+ <Button size="sm" variant="ghost" isIconOnly aria-label="Previous question" isDisabled={disabled || index === 0} onPress={() => setIndex(index - 1)}><ChevronLeft size={18} /></Button>
90
+ <span className="wi-count" aria-live="polite">{index + 1} of {questions.length}</span>
91
+ <Button size="sm" variant="ghost" isIconOnly aria-label="Next question" isDisabled={disabled || index === questions.length - 1} onPress={() => setIndex(index + 1)}><ChevronRight size={18} /></Button>
92
+ <Button size="sm" variant="ghost" isIconOnly aria-label="Skip remaining questions" isDisabled={disabled} onPress={() => finish()}><X size={18} /></Button>
93
+ </nav></header>
94
+ <h3 ref={heading} tabIndex={-1} id={titleId}>{question.question}</h3>
95
+ <div className="wi-choices" role={question.multiSelect ? "group" : "radiogroup"} aria-labelledby={titleId}>{question.options?.map((option, position) => <label className="wi-choice" key={option.label} data-selected={!custom[index] && answers[index]?.includes(option.label)} title={option.description}>
96
+ <input type={question.multiSelect ? "checkbox" : "radio"} name={titleId} value={option.label} disabled={disabled} checked={!custom[index] && Boolean(answers[index]?.includes(option.label))} onChange={() => {
97
+ setAnswers(values => ({ ...values, [index]: question.multiSelect ? values[index]?.includes(option.label) ? values[index].filter(value => value !== option.label) : [...(values[index] ?? []), option.label] : [option.label] }));
98
+ setCustom(values => ({ ...values, [index]: "" }));
99
+ }} /><span className="wi-choice-number" aria-hidden="true">{position + 1}</span><span>{option.label}</span><ArrowRight className="wi-choice-arrow" size={18} aria-hidden="true" />
100
+ </label>)}</div>
101
+ <footer className="wi-question-footer"><label className="wi-custom"><span className="wi-choice-number"><Pencil size={16} /></span><input aria-label="Write your own answer" placeholder="Or write your own answer" disabled={disabled} value={custom[index] ?? ""} onChange={event => { const text = event.target.value; setCustom(values => ({ ...values, [index]: text })); setAnswers(values => ({ ...values, [index]: [] })); }} onKeyDown={event => { if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229 && answer) { event.preventDefault(); next(false); } }} /></label>
102
+ <Button size="sm" variant="outline" isDisabled={disabled} onPress={() => next(true)}>Skip</Button><Button size="sm" isDisabled={disabled || !answer} isPending={pending} onPress={() => next(false)}>{index === questions.length - 1 ? "Submit" : "Next"}</Button>
103
+ </footer>{feedback}
104
+ </section>;
105
+ }
@@ -0,0 +1,86 @@
1
+ /* Shared approved Work message-flow geometry. */
2
+ .p-page > .agent-grid:has(.wm-scroll) { position: relative; flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr); }
3
+ .agent-grid .stream-panel:has(> .wm-scroll) { height: 100%; min-height: 0; overflow: visible; }
4
+ .stream-panel:has(> .wm-scroll) > .wpb-conversation { position: absolute; inset-inline: 0; bottom: 0; z-index: 20; }
5
+ .wm-scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: none; padding: 24px 0 calc(var(--wm-dock-height, 58px) + 24px); overflow-anchor: auto; }
6
+ .wm-conversation { width: min(var(--wpb-panel-width), calc(100% - 2 * var(--wpb-panel-gutter))); margin-inline: auto; }
7
+ .wm-turn + .wm-turn { margin-top: 48px; }
8
+ /* Only the last turn keeps the anchor's room; a later turn without a user
9
+ message, such as one a background task starts, follows directly. */
10
+ .wm-turn[data-new-user-anchor]:last-child { min-height: calc(var(--wm-viewport-height) * 2 / 3); }
11
+ .wm-user { display: flex; justify-content: flex-end; margin-bottom: 32px; }
12
+ .wm-user img { display: block; max-width: 100%; max-height: 240px; object-fit: contain; border-radius: 12px; }
13
+ .wm-user p { max-width: 85%; width: fit-content; margin: 0; padding: 11px 18px; border-radius: 24px; background: var(--accent); color: var(--accent-foreground); font-size: 15px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
14
+ .wm-process-toggle { display: flex; align-items: center; gap: 7px; padding: 6px 0; width: 100%; color: var(--muted); text-align: left; font-size: 14px; line-height: 22px; border-bottom: 1px solid color-mix(in oklch, var(--separator) 45%, transparent); }
15
+ .wm-process-toggle:hover { color: var(--foreground); }
16
+ .wm-process-toggle:focus-visible { outline: 2px solid var(--focus); outline-offset: 4px; border-radius: 4px; }
17
+ .wm-process-toggle > svg { transform: rotate(-90deg); transition: transform 150ms; }
18
+ .wm-process-toggle > .wm-chevron-open { transform: none; }
19
+ .wm-process { padding-top: 12px; color: var(--muted); }
20
+ .wm-process .app-markdown { color: inherit; }
21
+ .wm-conversation .app-markdown h1 { font-size: 22px; }
22
+ .wm-conversation .app-markdown h2 { font-size: 20px; }
23
+ .wm-conversation .app-markdown h3 { font-size: 18px; }
24
+ .wm-conversation .app-markdown :is(h4, h5, h6) { font-size: 16px; }
25
+ @media (max-width: 560px) {
26
+ .wm-conversation .app-markdown h1 { font-size: 20px; }
27
+ }
28
+ .wm-process > * + * { margin-top: 10px; }
29
+ .wm-activity { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 14px; line-height: 1.6; }
30
+ .wm-activity svg { flex-shrink: 0; }
31
+ .wm-tool-group summary { display: flex; align-items: center; gap: 7px; width: fit-content; cursor: pointer; font-size: 14px; line-height: 24px; list-style: none; }
32
+ .wm-tool-group summary::-webkit-details-marker { display: none; }
33
+ .wm-tool-group:not([open]) summary > svg:last-child { transform: rotate(-90deg); }
34
+ .wm-question-record dl { margin: 12px 0 0 24px; font-size: 14px; line-height: 1.6; }
35
+ .wm-question-record dl > div + div { margin-top: 12px; }
36
+ .wm-question-record dt { font-weight: 500; }
37
+ .wm-question-record dd { margin: 3px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
38
+ .wm-tool-group .wm-activity { margin-top: 5px; }
39
+ .wm-tool-group .wm-activity span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
40
+ .wm-thinking { display: inline-block; width: fit-content; color: transparent; background: linear-gradient(100deg, color-mix(in oklch, var(--muted) 55%, var(--surface)) 35%, var(--foreground) 50%, color-mix(in oklch, var(--muted) 55%, var(--surface)) 65%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; animation: wm-thinking 1.5s linear infinite; }
41
+ .wm-result { padding-top: 22px; }
42
+ .wm-process[hidden] + .wm-result { padding-top: 10px; }
43
+ .wm-result-actions { display: flex; align-items: center; gap: 8px; margin-top: 16px; color: var(--muted); }
44
+ .wm-result-actions .button { color: inherit; width: 30px; height: 30px; min-width: 30px; }
45
+ .wm-result-actions [role="alert"] { font-size: 12px; }
46
+ .wm-result-actions time { font-size: 12px; font-variant-numeric: tabular-nums; }
47
+ .wm-toc { position: absolute; right: -28px; top: 32%; z-index: 19; width: 28px; max-height: 45%; overflow-y: auto; scrollbar-width: none; border-radius: 14px; padding-block: 6px; }
48
+ .wm-toc:is(:hover, :focus-within) { width: min(300px, 90%); background: var(--surface); box-shadow: 0 4px 20px rgb(0 0 0 / 8%); padding: 10px 6px; }
49
+ .wm-toc-item { display: flex; align-items: center; justify-content: flex-end; gap: 12px; width: 100%; min-height: 16px; padding: 0 4px; color: var(--muted); border-radius: 6px; text-align: left; font-size: 13px; line-height: 16px; }
50
+ .wm-toc:is(:hover, :focus-within) .wm-toc-item { min-height: 24px; }
51
+ .wm-toc-item:hover { background: var(--surface-secondary); }
52
+ .wm-toc-item:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
53
+ .wm-toc-item[aria-current] { color: var(--foreground); }
54
+ .wm-toc-mark { flex-shrink: 0; width: 12px; height: 2px; border-radius: 2px; background: currentColor; opacity: .45; }
55
+ .wm-toc-item[aria-current] .wm-toc-mark { width: 20px; opacity: 1; }
56
+ .wm-toc-label { display: none; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
57
+ .wm-toc-number { display: inline-block; width: 22px; color: var(--muted); font-variant-numeric: tabular-nums; }
58
+ .wm-toc:is(:hover, :focus-within) .wm-toc-label { display: block; }
59
+ @keyframes wm-thinking { from { background-position: 150% 0; } to { background-position: -150% 0; } }
60
+ @media (prefers-reduced-motion: reduce) { .wm-thinking { animation: none; background: none; color: var(--muted); } .wm-process-toggle > svg { transition: none; } }
61
+ @media (max-width: 740px) { .wm-user p { max-width: 92%; } }
62
+ @media (max-width: 900px) {
63
+ .wm-toc { right: 0; }
64
+ .agent-grid:has(.wm-scroll) .cr-card { max-height: calc(100dvh - 200px); }
65
+ }
66
+
67
+ .wm-process-toggle.button { justify-content: flex-start; height: auto; min-width: 0; border-radius: 0; background: transparent; font-weight: 400; }
68
+ .wm-tool-summary.button { display: flex; gap: 7px; justify-content: flex-start; width: fit-content; min-width: 0; height: auto; padding: 0; background: transparent; color: inherit; font-size: 14px; font-weight: 400; line-height: 24px; }
69
+ .wm-tool-summary[aria-expanded="false"] > svg { transform: rotate(-90deg); }
70
+ .wm-activity.button { height: auto; min-width: 0; max-width: 100%; padding: 0; justify-content: flex-start; background: transparent; font-weight: 400; }
71
+ .wm-activity .wm-tool-failed { flex-shrink: 0; color: var(--danger); }
72
+ .wm-toc-item.button { height: auto; min-width: 0; font-weight: 400; background: transparent; }
73
+ .wm-tool-detail { padding: 10px 0 4px 26px; font-size: 13px; }
74
+ .wm-tool-detail pre { max-height: 360px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono); line-height: 1.6; }
75
+ .wm-detail-pages { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-variant-numeric: tabular-nums; }
76
+ .wm-latest { position: absolute; bottom: calc(var(--wm-dock-height, 58px) + 8px); left: 50%; transform: translateX(-50%); z-index: 21; }
77
+ .wm-error, .wm-assistant { color: var(--foreground); }
78
+ .wm-note { margin: 0; color: var(--muted); font-size: 0.8125rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
79
+
80
+ .agent-grid:has(.wm-scroll) > .cr-rail { position: absolute; top: 0; right: 0; margin: 0; width: min(var(--context-rail-width), 100%); z-index: 19; }
81
+
82
+ .wm-work-status { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex-shrink: 0; color: var(--muted); }
83
+
84
+ .wm-tool-reader > section + section { margin-top: 16px; }
85
+ .wm-tool-reader h4 { margin-bottom: 8px; font-size: inherit; font-weight: 500; color: var(--muted); }
86
+
@@ -0,0 +1,352 @@
1
+ import { Fragment, memo, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
2
+ import { Button, Tooltip } from "@heroui/react";
3
+ import { ArrowDown, Check, ChevronDown, Copy, FilePenLine, FileSearch, Search, Terminal, Wrench } from "lucide-react";
4
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
5
+ import { ContentBlockType, MessageDetailFormat, ResultStatus, UnifiedMessageKind, type UnifiedMessage } from "@talgent/workbench-contracts/dispatcher/public/v1/unified_message_pb";
6
+ import { Markdown } from "../markdown";
7
+ import { ToolMessageDetail } from "./tool-message-detail";
8
+ import { ToolReadingContent } from "./tool-reading-content";
9
+ import { WorkQuestionRecord } from "./work-question-record";
10
+ import type { useWorkConversation } from "../../hooks/use-work-conversation";
11
+ import type { WorkTurn } from "../../lib/work-conversation";
12
+ import "./work-message-flow.css";
13
+
14
+ export interface ToolPreview {
15
+ workId: string;
16
+ toolCallId: string;
17
+ title: string;
18
+ }
19
+
20
+ type Conversation = ReturnType<typeof useWorkConversation>;
21
+ type OpenTool = (tool: ToolPreview, trigger: HTMLElement) => void;
22
+
23
+ export function WorkMessageFlow({ conversation, currentTurnId, waiting, onOpenTool }: {
24
+ conversation: Conversation; currentTurnId?: string; waiting: boolean; onOpenTool: OpenTool;
25
+ }) {
26
+ const scroller = useRef<HTMLDivElement>(null);
27
+ const follow = useRef(true);
28
+ const hold = useRef(false);
29
+ const anchored = useRef<string | undefined>(undefined);
30
+ // The first visible message when a page was requested, kept at its current
31
+ // place while the request runs, and the window it was taken from: the page
32
+ // lands only in a later window, and the reader keeps any scrolling since.
33
+ const scrollAnchor = useRef<{ id: string; top: number; turns: Conversation["turns"] } | null>(null);
34
+ const lastTop = useRef(0);
35
+ const touchY = useRef<number | null>(null);
36
+ const frame = useRef(0);
37
+ const [activeTurn, setActiveTurn] = useState("");
38
+ // Away from the latest messages without browsing history pages.
39
+ const [away, setAway] = useState(false);
40
+ // Keep at most one inline output reader mounted across the whole window.
41
+ const [detailId, setDetailId] = useState<string | null>(null);
42
+ const navigationTurns = conversation.navigationTurns;
43
+ const navigation = useRef(navigationTurns);
44
+ useEffect(() => { navigation.current = navigationTurns; });
45
+ const pendingTurn = useRef<string | null>(null);
46
+ // Locating the current turn reads every anchor's geometry: once a frame.
47
+ const measure = useCallback((element: HTMLElement) => {
48
+ if (frame.current) return;
49
+ frame.current = requestAnimationFrame(() => {
50
+ frame.current = 0;
51
+ const line = element.getBoundingClientRect().top + element.clientHeight / 3 + 1;
52
+ const users = navigation.current.map(user => document.getElementById(`wm-user-${user.id}`)).filter((user): user is HTMLElement => user !== null);
53
+ setActiveTurn((users.filter(user => user.getBoundingClientRect().top <= line).at(-1) ?? users[0])?.id ?? "");
54
+ // A view placed on purpose, at the user's new message or a chosen turn, is not away.
55
+ setAway(!follow.current && !hold.current && element.scrollHeight - element.scrollTop - element.clientHeight > element.clientHeight / 3);
56
+ });
57
+ }, []);
58
+ const scrollToTurn = useCallback((id: string) => {
59
+ const element = scroller.current, target = document.getElementById(`wm-user-${id}`);
60
+ if (!element || !target) return false;
61
+ follow.current = false; hold.current = true;
62
+ target.querySelector<HTMLElement>("p")?.focus({ preventScroll: true });
63
+ element.scrollTo({ top: element.scrollTop + target.getBoundingClientRect().top - element.getBoundingClientRect().top - 24, behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "instant" : "smooth" });
64
+ setActiveTurn(target.id);
65
+ return true;
66
+ }, []);
67
+ useLayoutEffect(() => {
68
+ const element = scroller.current;
69
+ if (!element) return;
70
+ element.style.setProperty("--wm-viewport-height", `${element.clientHeight}px`);
71
+ if (pendingTurn.current) {
72
+ if (!conversation.loading && scrollToTurn(pendingTurn.current)) pendingTurn.current = null;
73
+ return;
74
+ }
75
+ if (conversation.newUserId && anchored.current !== conversation.newUserId) {
76
+ const target = document.getElementById(`wm-user-${conversation.newUserId}`);
77
+ if (target) {
78
+ follow.current = false; hold.current = true; anchored.current = conversation.newUserId;
79
+ element.scrollTop += target.getBoundingClientRect().top - element.getBoundingClientRect().top - element.clientHeight / 3;
80
+ }
81
+ } else if (scrollAnchor.current && !conversation.loading && scrollAnchor.current.turns !== conversation.turns) {
82
+ const target = document.getElementById(scrollAnchor.current.id);
83
+ if (target) element.scrollTop += target.getBoundingClientRect().top - scrollAnchor.current.top;
84
+ scrollAnchor.current = null;
85
+ } else if (follow.current) element.scrollTop = element.scrollHeight;
86
+ measure(element);
87
+ }, [conversation.turns, conversation.newUserId, conversation.loading, scrollToTurn, measure]);
88
+ useEffect(() => {
89
+ const element = scroller.current;
90
+ if (!element) return;
91
+ const dock = element.parentElement?.querySelector<HTMLElement>(".wi-dock");
92
+ const content = element.firstElementChild;
93
+ // Content can also grow after it renders (highlighted code, diagrams,
94
+ // images) and the dock with a request card: a followed view stays at the end.
95
+ const observer = new ResizeObserver(() => {
96
+ element.style.setProperty("--wm-viewport-height", `${element.clientHeight}px`);
97
+ element.parentElement?.style.setProperty("--wm-dock-height", `${dock?.offsetHeight ?? 0}px`);
98
+ if (follow.current) element.scrollTop = element.scrollHeight;
99
+ });
100
+ observer.observe(element);
101
+ if (dock) observer.observe(dock);
102
+ if (content) observer.observe(content);
103
+ return () => { observer.disconnect(); cancelAnimationFrame(frame.current); };
104
+ }, []);
105
+ // Reading upward, by any input, releases following at once: a message
106
+ // arriving before the view has moved far must not pull the reader back.
107
+ const leaveLatest = () => { hold.current = false; follow.current = false; };
108
+ const loadPage = (direction: "older" | "newer") => {
109
+ const element = scroller.current;
110
+ if (!element || conversation.loading || !(direction === "older" ? conversation.hasMore : conversation.hasLaterMessages)) return;
111
+ const top = element.getBoundingClientRect().top;
112
+ const visible = [...element.querySelectorAll<HTMLElement>("[data-message-id]")].find(item => item.getBoundingClientRect().bottom > top);
113
+ if (visible) scrollAnchor.current = { id: visible.id, top: visible.getBoundingClientRect().top, turns: conversation.turns };
114
+ follow.current = false;
115
+ if (direction === "older") conversation.loadOlder();
116
+ else conversation.loadNewer();
117
+ };
118
+ return <>
119
+ <div className="wm-scroll p-fill" ref={scroller} onWheel={event => { if (event.deltaY < 0) leaveLatest(); else hold.current = false; }}
120
+ onTouchStart={event => { touchY.current = event.touches[0]?.clientY ?? null; }}
121
+ onTouchMove={event => {
122
+ const y = event.touches[0]?.clientY;
123
+ if (y !== undefined && touchY.current !== null && y > touchY.current) leaveLatest(); else hold.current = false;
124
+ touchY.current = y ?? null;
125
+ }}
126
+ onKeyDown={event => {
127
+ if (["ArrowUp", "PageUp", "Home"].includes(event.key) || (event.key === " " && event.shiftKey)) leaveLatest();
128
+ else if (["ArrowDown", "PageDown", "End", " "].includes(event.key)) hold.current = false;
129
+ }} onScroll={event => {
130
+ const element = event.currentTarget;
131
+ const distance = element.scrollHeight - element.scrollTop - element.clientHeight;
132
+ const upward = element.scrollTop < lastTop.current - 1;
133
+ lastTop.current = element.scrollTop;
134
+ // At the very end the view follows again; moving upward near it does not.
135
+ follow.current = !hold.current && !conversation.browsingHistory && (distance <= 2 || (!upward && distance < 80));
136
+ const anchor = scrollAnchor.current && document.getElementById(scrollAnchor.current.id);
137
+ if (anchor) scrollAnchor.current!.top = anchor.getBoundingClientRect().top;
138
+ measure(element);
139
+ if (hold.current || conversation.error) return;
140
+ if (element.scrollTop < 100) loadPage("older");
141
+ else if (distance < 100) loadPage("newer");
142
+ }}>
143
+ <div className="wm-conversation">
144
+ {conversation.loading && <p className="sr-only" role="status">Loading messages…</p>}
145
+ {conversation.turns.map(turn => <TurnView key={turn.key} turn={turn} active={Boolean(turn.turnId && turn.turnId === currentTurnId)} waiting={waiting} anchoredId={conversation.newUserId} detailId={detailId} onDetail={setDetailId} onOpenTool={onOpenTool} />)}
146
+ </div>
147
+ </div>
148
+ {(conversation.browsingHistory || away) && <Tooltip><Button className="wm-latest" size="sm" variant="secondary" isIconOnly aria-label={conversation.hasNewer ? "New messages · Back to latest" : "Back to latest"} onPress={() => {
149
+ follow.current = true; hold.current = false; scrollAnchor.current = null; pendingTurn.current = null; setAway(false);
150
+ // The latest messages are already in this window unless browsing history.
151
+ if (conversation.browsingHistory) conversation.showLatest();
152
+ else if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight;
153
+ }}><ArrowDown size={17} aria-hidden="true" /></Button><Tooltip.Content>{conversation.hasNewer ? "New messages · Back to latest" : "Back to latest"}</Tooltip.Content></Tooltip>}
154
+ {(navigationTurns.length > 0 || conversation.indexError) && <nav className="wm-toc" aria-label="Conversation turns">
155
+ {(conversation.hasOlderTurns || conversation.indexError) && <Button variant="ghost" className="wm-toc-item" isDisabled={conversation.indexLoading} aria-label={conversation.indexError ? "Retry turn navigation" : "Load earlier turns"} onPress={conversation.indexError ? conversation.retryIndex : conversation.loadOlderTurns}><span className="wm-toc-label">{conversation.indexError ? "Retry" : "Earlier turns"}</span><ChevronDown size={12} className="wm-chevron-open" /></Button>}
156
+ {navigationTurns.map((user, index) => <Button key={user.id} variant="ghost" className="wm-toc-item" aria-label={`Turn ${index + 1}: ${user.title}`} aria-controls={`wm-user-${user.id}`} aria-current={activeTurn === `wm-user-${user.id}` ? "location" : undefined} onPress={() => {
157
+ scrollAnchor.current = null;
158
+ if (scrollToTurn(user.id)) { pendingTurn.current = null; return; }
159
+ follow.current = false; hold.current = true; pendingTurn.current = user.id;
160
+ conversation.showTurn(user);
161
+ }}><span className="wm-toc-label"><span className="wm-toc-number">{index + 1}</span>{user.title}</span><span className="wm-toc-mark" aria-hidden="true" /></Button>)}
162
+ </nav>}
163
+ </>;
164
+ }
165
+
166
+ const TurnView = memo(function TurnView({ turn, active, waiting, anchoredId, detailId, onDetail, onOpenTool }: {
167
+ turn: WorkTurn; active: boolean; waiting: boolean; anchoredId?: string; detailId: string | null;
168
+ onDetail: (id: string | null) => void; onOpenTool: OpenTool;
169
+ }) {
170
+ const users = turn.messages.filter(message => message.kind === UnifiedMessageKind.USER);
171
+ const results = turn.messages.filter(message => message.payload.case === "result");
172
+ const lastResult = results.at(-1)?.payload;
173
+ const result = lastResult?.case === "result" ? lastResult.value : undefined;
174
+ const running = active && !result;
175
+ // Each Steer, input sent while the Turn ran, starts a segment of the Turn.
176
+ // A Turn a background task started has no user message before its work.
177
+ const segments: { user?: UnifiedMessage; process: UnifiedMessage[] }[] = [{ process: [] }];
178
+ for (const message of turn.messages) {
179
+ if (message.payload.case === "result") continue;
180
+ const current = segments.at(-1)!;
181
+ if (message.kind !== UnifiedMessageKind.USER) current.process.push(message);
182
+ else if (!current.user && !current.process.length) current.user = message;
183
+ else segments.push({ user: message, process: [] });
184
+ }
185
+ // Errors without turn attribution stay visible without inventing a turn.
186
+ if (!turn.turnId && !users.length) return <article className="wm-turn">{segments[0].process.map(message => <ProcessMessage key={message.id} message={message} detailId={detailId} onDetail={onDetail} />)}{results.map(message => <Result key={message.id} message={message} />)}</article>;
187
+ const start = turn.messages[0]?.timestamp;
188
+ const seconds = Math.max(0, Math.floor((result ? Number(result.durationMs) : 0) / 1000));
189
+ const label: ReactNode = result ? result.status === ResultStatus.SUCCESS ? seconds ? `Worked for ${duration(seconds)}` : "View work" : result.status === ResultStatus.FAILED ? "Work failed" : result.status === ResultStatus.TIMED_OUT ? "Work timed out" : result.status === ResultStatus.REQUIRES_INPUT ? "Waiting for input" : `Stopped after ${duration(seconds)}`
190
+ : running ? waiting ? "Waiting for your response" : <WorkingFor since={start ? timestampDate(start).getTime() : undefined} /> : "View work";
191
+ return <article className="wm-turn" data-turn-id={turn.turnId} data-new-user-anchor={users.some(user => user.id === anchoredId) || undefined}>
192
+ {segments.map((segment, index) => {
193
+ const last = index === segments.length - 1;
194
+ const next = segments[index + 1]?.user?.timestamp;
195
+ const from = segment.user?.timestamp ?? segment.process[0]?.timestamp;
196
+ const worked = next && from ? Math.max(0, Math.floor((timestampDate(next).getTime() - timestampDate(from).getTime()) / 1000)) : 0;
197
+ return <Fragment key={segment.user?.id ?? "process"}>
198
+ {segment.user && <UserMessage user={segment.user} />}
199
+ <Segment messages={segment.process} running={last && running} waiting={waiting} settled={!last || result?.status === ResultStatus.SUCCESS} shown={last ? Boolean(segment.process.length || running || result) : segment.process.length > 0}
200
+ label={last ? label : `Worked for ${duration(worked)} before your update`} detailId={detailId} onDetail={onDetail} onOpenTool={onOpenTool} />
201
+ </Fragment>;
202
+ })}
203
+ {results.map(message => <Result key={message.id} message={message} />)}
204
+ </article>;
205
+ });
206
+
207
+ // The running clock ticks alone, so a long Turn is not rendered every second.
208
+ function WorkingFor({ since }: { since?: number }) {
209
+ const [now, setNow] = useState(Date.now);
210
+ useEffect(() => {
211
+ const timer = window.setInterval(() => setNow(Date.now()), 1000);
212
+ return () => window.clearInterval(timer);
213
+ }, []);
214
+ return <>{`Working for ${duration(Math.max(0, Math.floor((since === undefined ? 0 : now - since) / 1000)))}`}</>;
215
+ }
216
+
217
+ function duration(seconds: number) {
218
+ return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
219
+ }
220
+
221
+ function UserMessage({ user }: { user: UnifiedMessage }) {
222
+ return <div className="wm-user" id={`wm-user-${user.id}`} data-message-id={user.id}><p tabIndex={-1}>{user.payload.case === "message" && user.payload.value.content.map((block, index) =>
223
+ block.type === ContentBlockType.TEXT ? <Fragment key={index}>{index > 0 ? "\n\n" : ""}{block.text}</Fragment>
224
+ : block.type === ContentBlockType.IMAGE ? /^(image\/(png|jpeg|gif|webp))$/.test(block.mimeType)
225
+ ? <img key={index} src={`data:${block.mimeType};base64,${block.text}`} alt="Attached image" loading="lazy" decoding="async" />
226
+ : <span key={index}>Image unavailable</span>
227
+ : null,
228
+ )}</p></div>;
229
+ }
230
+
231
+ // A segment's process is collapsed once settled: a successful Turn's last
232
+ // segment, or any segment a later Steer followed. Otherwise it stays open.
233
+ function Segment({ messages, running, waiting, settled, shown, label, detailId, onDetail, onOpenTool }: {
234
+ messages: UnifiedMessage[]; running: boolean; waiting: boolean; settled: boolean; shown: boolean; label: ReactNode;
235
+ detailId: string | null; onDetail: (id: string | null) => void; onOpenTool: OpenTool;
236
+ }) {
237
+ const processId = useId();
238
+ const phase = settled ? "completed" : "process";
239
+ const [expanded, setExpanded] = useState({ process: true, completed: false });
240
+ const open = expanded[phase];
241
+ if (!shown) return null;
242
+ return <>
243
+ <Button variant="ghost" className="wm-process-toggle" aria-expanded={open} aria-controls={processId} onPress={() => setExpanded(value => ({ ...value, [phase]: !open }))}><span>{label}</span><ChevronDown size={15} className={open ? "wm-chevron-open" : ""} aria-hidden="true" /></Button>
244
+ <div className="wm-process" id={processId} hidden={!open}>{open && <Process messages={messages} running={running} waiting={waiting} detailId={detailId} onDetail={onDetail} onOpenTool={onOpenTool} />}</div>
245
+ </>;
246
+ }
247
+
248
+ function Process({ messages, running, waiting, detailId, onDetail, onOpenTool }: {
249
+ messages: UnifiedMessage[]; running: boolean; waiting: boolean; detailId: string | null; onDetail: (id: string | null) => void; onOpenTool: OpenTool;
250
+ }) {
251
+ const observations = new Map(messages.flatMap(message => message.payload.case === "observation" ? [[message.payload.value.toolCallId, message] as const] : []));
252
+ const actionIds = new Set(messages.flatMap(message => message.payload.case === "action" ? [message.payload.value.toolCallId] : []));
253
+ const questions = new Map(messages.flatMap(message => message.payload.case === "interaction" ? [[message.payload.value.toolCallId, message] as const] : []));
254
+ const questionActions = new Map(messages.flatMap(message => message.payload.case === "action" && questions.has(message.payload.value.toolCallId) ? [[message.payload.value.toolCallId, message] as const] : []));
255
+ const shownQuestions = new Set<string>();
256
+ const entries: (UnifiedMessage | UnifiedMessage[])[] = [];
257
+ for (const message of messages) {
258
+ if (message.payload.case === "permission") continue;
259
+ const toolCallId = message.payload.case === "action" || message.payload.case === "observation" || message.payload.case === "interaction" ? message.payload.value.toolCallId : undefined;
260
+ const question = toolCallId ? questions.get(toolCallId) : undefined;
261
+ if (question) {
262
+ if (!shownQuestions.has(toolCallId!)) { entries.push(question); shownQuestions.add(toolCallId!); }
263
+ continue;
264
+ }
265
+ if (message.payload.case === "observation" && actionIds.has(message.payload.value.toolCallId)) continue;
266
+ if (message.payload.case === "action" || message.payload.case === "observation") {
267
+ const last = entries.at(-1);
268
+ if (Array.isArray(last)) last.push(message); else entries.push([message]);
269
+ } else entries.push(message);
270
+ }
271
+ const thinking = running && !waiting;
272
+ return <>{entries.map((entry, index) => Array.isArray(entry)
273
+ ? <ToolGroup key={entry[0].id} tools={entry} observations={observations} thinking={thinking && index === entries.length - 1} detailId={detailId} onDetail={onDetail} onOpenTool={onOpenTool} />
274
+ : entry.payload.case === "interaction"
275
+ ? <WorkQuestionRecord key={entry.payload.value.interactionId} message={entry} action={questionActions.get(entry.payload.value.toolCallId)} output={observations.get(entry.payload.value.toolCallId)} open={detailId === entry.id} onToggle={() => onDetail(detailId === entry.id ? null : entry.id)} />
276
+ : <ProcessMessage key={entry.id} message={entry} detailId={detailId} onDetail={onDetail} />)}
277
+ {thinking && !Array.isArray(entries.at(-1)) && <div className="wm-thinking" role="status">Thinking…</div>}
278
+ </>;
279
+ }
280
+
281
+ function ToolGroup({ tools, observations, thinking, detailId, onDetail, onOpenTool }: {
282
+ tools: UnifiedMessage[]; observations: Map<string, UnifiedMessage>; thinking: boolean; detailId: string | null; onDetail: (id: string | null) => void; onOpenTool: OpenTool;
283
+ }) {
284
+ const [open, setOpen] = useState(false);
285
+ const contentId = useId();
286
+ return <div className="wm-tool-group">
287
+ <Button variant="ghost" className="wm-tool-summary" aria-expanded={open} aria-controls={contentId} onPress={() => setOpen(!open)}><span className={thinking ? "wm-thinking" : undefined}>{thinking ? "Thinking…" : toolActivity(tools)}</span><ChevronDown size={14} aria-hidden="true" /></Button>
288
+ {open && <div id={contentId}>{tools.map(message => {
289
+ const action = message.payload.case === "action" ? message.payload.value : undefined;
290
+ const output = action ? observations.get(action.toolCallId) : message;
291
+ const name = action?.toolName ?? "Tool output";
292
+ const label = action?.summary ? `${name}: ${action.summary}` : name;
293
+ const Icon = name === "Read" ? FileSearch : name === "Bash" ? Terminal : /Search|Grep|Glob|Fetch/.test(name) ? Search : /^(Edit|Write)$/.test(name) ? FilePenLine : Wrench;
294
+ const preview = action && /^(Read|Write|Edit)$/.test(name);
295
+ const detail = output?.detailFormat ? output : message.detailFormat ? message : undefined;
296
+ const isError = output?.payload.case === "observation" && output.payload.value.isError;
297
+ return <div key={message.id} id={`wm-message-${message.id}`} data-message-id={message.id}>
298
+ <Button variant="ghost" className="wm-activity" aria-expanded={preview ? undefined : detailId === message.id} isDisabled={!preview && !detail} onPress={event => {
299
+ if (preview && event.target instanceof HTMLElement) onOpenTool({ workId: message.agentWorkId, toolCallId: action.toolCallId, title: label }, event.target);
300
+ else if (detail) onDetail(detailId === message.id ? null : message.id);
301
+ }}><Icon size={17} strokeWidth={1.6} /><span title={label}>{label}</span>{isError && <span className="wm-tool-failed">Failed</span>}</Button>
302
+ {!preview && detail && detailId === message.id && <>{action ? <ToolReadingContent tool={{ workId: message.agentWorkId, toolCallId: action.toolCallId, title: label }} /> : <ToolMessageDetail workId={message.agentWorkId} messageId={detail.id} />}</>}
303
+ </div>;
304
+ })}</div>}
305
+ </div>;
306
+ }
307
+
308
+ function ProcessMessage({ message, detailId, onDetail }: { message: UnifiedMessage; detailId: string | null; onDetail: (id: string | null) => void }) {
309
+ const payload = message.payload;
310
+ let text = "";
311
+ if (payload.case === "message") text = messageText(message);
312
+ else if (payload.case === "error") text = payload.value.message || payload.value.code;
313
+ if (!text) return null;
314
+ // The Harness's own note, such as an interruption marker, reads as it was
315
+ // written and as part of the work, never as a person's input.
316
+ if (message.kind === UnifiedMessageKind.HARNESS_NOTE) return <p id={`wm-message-${message.id}`} data-message-id={message.id} className="wm-note">{text}</p>;
317
+ return <div id={`wm-message-${message.id}`} data-message-id={message.id} className={payload.case === "error" ? "wm-error" : "wm-assistant"}>
318
+ <Markdown>{text}</Markdown>
319
+ {payload.case === "error" && message.detailFormat !== MessageDetailFormat.UNSPECIFIED && <><Button variant="ghost" size="sm" aria-expanded={detailId === message.id} onPress={() => onDetail(detailId === message.id ? null : message.id)}>Error details</Button>{detailId === message.id && <ToolMessageDetail workId={message.agentWorkId} messageId={message.id} />}</>}
320
+ </div>;
321
+ }
322
+
323
+ function Result({ message }: { message: UnifiedMessage }) {
324
+ const [copied, setCopied] = useState(false);
325
+ const [failed, setFailed] = useState(false);
326
+ if (message.payload.case !== "result") return null;
327
+ const text = message.payload.value.text;
328
+ const time = message.timestamp ? timestampDate(message.timestamp) : undefined;
329
+ return <div className="wm-result" id={`wm-message-${message.id}`} data-message-id={message.id}>
330
+ <Markdown>{text}</Markdown>
331
+ <div className="wm-result-actions"><Button size="sm" variant="ghost" isIconOnly aria-label={copied ? "Copied" : "Copy response"} onPress={async () => {
332
+ try { await navigator.clipboard.writeText(text); setCopied(true); setFailed(false); } catch { setFailed(true); }
333
+ }}>{copied ? <Check size={17} /> : <Copy size={17} />}</Button>{time && <time dateTime={time.toISOString()} title={`Received ${time.toLocaleString("en-US")}`}>{time.toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false })}</time>}{failed && <span role="alert">Could not copy. Select the text to copy it.</span>}</div>
334
+ </div>;
335
+ }
336
+
337
+ function messageText(message: UnifiedMessage) {
338
+ return message.payload.case === "message" ? message.payload.value.content.filter(block => block.type === ContentBlockType.TEXT).map(block => block.text).filter(Boolean).join("\n\n") : "";
339
+ }
340
+
341
+ function toolActivity(tools: UnifiedMessage[]) {
342
+ const counts = new Map<string, number>();
343
+ for (const tool of tools) { const name = tool.payload.case === "action" ? tool.payload.value.toolName : "tool"; counts.set(name, (counts.get(name) ?? 0) + 1); }
344
+ if (counts.size > 1) return `Used tools · ${tools.length} calls`;
345
+ return [...counts].map(([name, count], index) => {
346
+ const text = name === "Read" ? `Read ${count === 1 ? "a file" : `${count} files`}`
347
+ : /^(Edit|Write)$/.test(name) ? `${name === "Edit" ? "Edited" : "Created"} ${count === 1 ? "a file" : `${count} files`}`
348
+ : name === "Bash" ? `Ran ${count === 1 ? "a command" : `${count} commands`}`
349
+ : /^(Grep|Glob)$/.test(name) ? "Searched project files" : /^(WebSearch|WebFetch)$/.test(name) ? "Searched the web" : `Used ${name}${count > 1 ? ` ${count} times` : ""}`;
350
+ return index ? text[0].toLowerCase() + text.slice(1) : text;
351
+ }).join(", ");
352
+ }
@@ -0,0 +1,20 @@
1
+ import { Check, Circle } from "lucide-react";
2
+ import type { WorkProgress } from "../../lib/work-progress";
3
+ import { RailSection } from "../rail/rail-section";
4
+ import { useExpandOnChange } from "../rail/use-expand-on-change";
5
+
6
+ // Progress belongs to the Work, independently of the business page showing it.
7
+ // Undefined is not loaded; null is a successful read with no items.
8
+ // The owning rail calls this even while empty, so the first later Progress
9
+ // report counts as a change and expands just like any subsequent report.
10
+ export function useWorkProgressSection({ progress, scope, visible }: { progress: WorkProgress | null | undefined; scope: string; visible: boolean }) {
11
+ const items = progress?.items ?? [];
12
+ const [open, setOpen] = useExpandOnChange({ scope, visible, defaultOpen: false,
13
+ content: progress === undefined ? undefined : items.map(item => `${item.status} ${item.content}`).join("\n") });
14
+ if (!items.length) return null;
15
+ return <RailSection title="Progress" summary={`${items.filter(item => item.status === "completed").length} of ${items.length} done`} open={open} onOpenChange={setOpen}>
16
+ <ul className="cr-todos" aria-label="Work progress">{items.map((item, index) => <li key={index} data-status={item.status} title={item.content}>
17
+ {item.status === "completed" ? <Check size={13} aria-label="Completed" /> : item.status === "in_progress" ? <span className="cr-todo-now" role="img" aria-label="In progress" /> : <Circle size={12} aria-label="Pending" />}<span>{item.content}</span>
18
+ </li>)}</ul>
19
+ </RailSection>;
20
+ }