@talgent/workbench-ui 0.0.0 → 0.2.0

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 +81 -5
  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,57 @@
1
+ import type { ReactNode } from "react";
2
+ import { Card, Chip, Tooltip, Typography } from "@heroui/react";
3
+ import { ArrowDown, ArrowUpRight, Box, CheckCheck, DatabaseZap, Medal } from "lucide-react";
4
+ import { DistributionChart, MetricCard, TimeChart } from "./dashboard-charts";
5
+ import { ModelUsageChart } from "./model-usage-chart";
6
+ import { formatDashboardNumber } from "../../lib/dashboard";
7
+ import { reportBucketLabels } from "../../lib/dashboard-report";
8
+ import { otherModels, type TokenAnalytics } from "../../lib/token-analytics";
9
+
10
+ const colors = { input: "#0a8f76", cacheRead: "#2563eb", cacheWrite: "#7c3aed", output: "#d97706" };
11
+ const tokenSeries = [{ key: "input", label: "Input", color: colors.input }, { key: "cacheRead", label: "Cache read", color: colors.cacheRead }, { key: "cacheWrite", label: "Cache write", color: colors.cacheWrite }, { key: "output", label: "Output", color: colors.output }] as const;
12
+ // Models and call mixes share one categorical order; Other is always neutral.
13
+ const palette = ["#26d0a8", "#2563eb", "#7c3aed", "#db2777", "#d97706"];
14
+ const shown = (value: number) => formatDashboardNumber(value);
15
+
16
+ /** One ranking of the report: who or what used the most tokens. */
17
+ export type TokenRanking = { title: string; rows: Array<{ key: string; label: string; totalTokens: number; icon: ReactNode; detail?: string }> };
18
+
19
+ /** Spend's report of token usage over a window: metric cards with the
20
+ * previous period, the token and model trends, the rankings and the call mixes. */
21
+ export function TokenReport({ analytics, rankings, hover, onHover }: {
22
+ analytics: TokenAnalytics; rankings: TokenRanking[];
23
+ hover: { index: number; chart: string } | null; onHover: (hover: { index: number; chart: string } | null) => void;
24
+ }) {
25
+ const { summary, period, tokenValues, previous } = analytics;
26
+ const points = analytics.tokenTrend.map((token, index) => ({ ...reportBucketLabels(period, index), input: token.input, cacheRead: token.cacheRead, cacheWrite: token.cacheWrite, output: token.output }));
27
+ const modelPoints = analytics.modelUsage.buckets.map((bucket, index) => ({ ...reportBucketLabels(period, index), usage: bucket.usage }));
28
+ const modelColors = Object.fromEntries(analytics.modelUsage.models.map((model, index) => [model, model === otherModels ? "var(--muted)" : palette[index % palette.length]]));
29
+ const metrics = [
30
+ { key: "total", label: "Total", hint: "Input + Cache read + Cache write + Output.", Icon: Box, color: "#64748b" },
31
+ { key: "input", label: "Input", hint: "Input tokens excluding cache reads and writes.", Icon: ArrowDown, color: colors.input },
32
+ { key: "cacheRead", label: "Cache read", hint: "Input tokens read from the prompt cache.", Icon: CheckCheck, color: colors.cacheRead },
33
+ { key: "cacheWrite", label: "Cache write", hint: "Input tokens written to the prompt cache.", Icon: DatabaseZap, color: colors.cacheWrite },
34
+ { key: "output", label: "Output", hint: "Model-generated output tokens.", Icon: ArrowUpRight, color: colors.output },
35
+ ] as const;
36
+ if (!analytics.metrics.totals.length && !analytics.metrics.buckets.length) {
37
+ return <Typography type="body-sm" color="muted" role="status">No Token records received in this window.</Typography>;
38
+ }
39
+ return <div className="p-analytics" aria-label="Token analytics">
40
+ {analytics.comparisonUnavailable && <Typography type="body-sm" color="muted" role="status">{analytics.comparisonUnavailable}</Typography>}
41
+ <div className="p-metric-grid p-token-metrics">{metrics.map(metric => <MetricCard key={metric.key} label={metric.label} hint={metric.hint} icon={<metric.Icon size={17} color={metric.color} />} value={shown(tokenValues[metric.key])} comparison={{ current: tokenValues[metric.key], previous: previous?.[metric.key] ?? null, period: analytics.window.toUpperCase(), bounds: period }} />)}</div>
42
+ <div className="p-trace-charts">
43
+ <TimeChart title="Token usage" kind="area" unit="tokens" points={points} series={[...tokenSeries]} summary={`${shown(tokenValues.total)} tokens`} formatValue={shown} hover={hover} onHover={onHover} />
44
+ <ModelUsageChart points={modelPoints} models={analytics.modelUsage.models} colors={modelColors} />
45
+ </div>
46
+ <div className={rankings.length > 1 ? "p-ranking-grid" : undefined}>{rankings.map(group => <Card key={group.title} role="group" aria-label={group.title}><Card.Header><Card.Title role="heading" aria-level={2}>{group.title}</Card.Title></Card.Header><Card.Content>
47
+ {group.rows.length ? <ol className="p-ranking">{group.rows.map((row, index) => <li key={row.key}>
48
+ <Chip size="sm" color={index === 0 ? "warning" : index === 1 ? "accent" : index === 2 ? "success" : "default"} variant="soft">{index < 3 && <Medal size={12} />}<Chip.Label>{index + 1}</Chip.Label></Chip>
49
+ {row.icon}
50
+ <Typography type="body-sm" truncate title={row.label}>{row.label}</Typography><Tooltip><Tooltip.Trigger className="shrink-0 ms-auto"><Typography type="body-sm" weight="medium">{shown(row.totalTokens)}</Typography></Tooltip.Trigger><Tooltip.Content>{row.totalTokens.toLocaleString()} tokens · {row.detail ?? row.key}</Tooltip.Content></Tooltip>
51
+ </li>)}</ol> : <Typography type="body-sm" color="muted">No observations</Typography>}
52
+ </Card.Content></Card>)}</div>
53
+ <div className="p-trace-distributions">{[
54
+ { title: "Model mix", data: summary.modelDistribution }, { title: "Tool mix", data: summary.toolDistribution }, { title: "Agent mix", data: summary.agentDistribution }, { title: "MCP mix", data: summary.mcpDistribution }, { title: "Skill mix", data: summary.skillDistribution },
55
+ ].map(group => <DistributionChart key={group.title} title={group.title} unit="calls" data={group.data.map((row, index) => ({ ...row, color: palette[index % palette.length] }))} />)}</div>
56
+ </div>;
57
+ }
@@ -0,0 +1,98 @@
1
+ import { useState } from "react";
2
+ import { Button, Tooltip } from "@heroui/react";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { UnifiedMessageKind } from "@talgent/workbench-contracts/dispatcher/public/v1/unified_message_pb";
5
+ import { streamClient } from "../../../lib/client";
6
+ import { formatCompactTokenCount } from "../../../lib/trace-view-model";
7
+ import { tokenCategories, tokenTotal } from "../../../lib/token-usage";
8
+ import { turnAxis, type Call, type ModelCall, type TimeRange, type ToolCall, type Turn } from "./turns";
9
+ import { formatClock, formatMillis } from "./work-status";
10
+
11
+ const shownCalls = 40;
12
+ const legend = [["model", "Model call"], ["tool", "Tool"], ["mcp", "MCP tool"], ["agent", "Subagent"], ["wait", "Waited for you"]] as const;
13
+ const visibleWaitMillis = 1000;
14
+
15
+ /** One Turn's calls on an axis covering only that Turn. */
16
+ export function TurnTimeline({ turn, workId, now }: { turn: Turn; workId: string; now: number }) {
17
+ const [showAll, setShowAll] = useState(false);
18
+ const range = { start: turn.start, end: turn.open ? Math.max(turn.end, now) : turn.end };
19
+ const axis = turnAxis(range, turn.calls);
20
+ const calls = showAll ? turn.calls : turn.calls.slice(0, shownCalls);
21
+ // The legend names only the kinds this Turn's bars use.
22
+ const kinds = new Set<string>(calls.flatMap(call => call.kind === "model" ? ["model"] : [call.family, ...(call.wait && call.wait.end - call.wait.start >= visibleWaitMillis ? ["wait"] : [])]));
23
+ if (!turn.calls.length) return <p className="wu-empty">No calls recorded in this Turn.</p>;
24
+ return <div className="wu-timeline">
25
+ <ul className="wu-legend" aria-label="Bar colours">
26
+ {legend.filter(([kind]) => kinds.has(kind)).map(([kind, label]) => <li key={kind}><i className="wu-bar-swatch" data-kind={kind} />{label}</li>)}
27
+ </ul>
28
+ <div className="wu-axis" aria-hidden="true">
29
+ <span>{formatClock(range.start)}</span><span>{formatClock(range.end)}</span>
30
+ {axis.breaks.map(at => <i key={at} className="wu-break" style={{ left: `${at}%` }} />)}
31
+ </div>
32
+ <ol className="wu-calls" aria-label="Calls in this Turn">
33
+ {calls.map(call => <li key={call.key}><CallRow call={call} workId={workId} bar={segment => ({ left: `${axis.position(segment.start)}%`, width: `${Math.max(axis.position(segment.end) - axis.position(segment.start), .6)}%` })} /></li>)}
34
+ </ol>
35
+ {turn.calls.length > calls.length && <Button size="sm" variant="ghost" onPress={() => setShowAll(true)}>Show {turn.calls.length - calls.length} more calls</Button>}
36
+ </div>;
37
+ }
38
+
39
+ function CallRow({ call, workId, bar }: { call: Call; workId: string; bar: (range: TimeRange) => { left: string; width: string } }) {
40
+ const [open, setOpen] = useState(false);
41
+ const label = call.kind === "model" ? "Model call" : call.name;
42
+ const waited = call.kind === "tool" && call.wait && call.wait.end - call.wait.start >= visibleWaitMillis ? call.wait : null;
43
+ const metric = call.kind === "model" ? formatCompactTokenCount(tokenTotal(call.usage)) : formatMillis(call.end - call.start);
44
+ return <Tooltip delay={0} closeDelay={100} isOpen={open} onOpenChange={setOpen}>
45
+ <Button variant="ghost" className="wu-call" aria-label={`${label}${call.kind === "tool" && call.source ? ` (${call.source})` : ""}, ${metric}`} onPress={() => setOpen(true)}>
46
+ <span className="wu-call-name"><span>{label}</span>{call.kind === "tool" && call.source && <span className="wu-muted">{call.source}</span>}</span>
47
+ <span className="wu-track" aria-hidden="true">
48
+ {waited && <span className="wu-bar" data-kind="wait" style={bar(waited)} />}
49
+ <span className="wu-bar" data-kind={call.kind === "tool" ? call.family : "model"} style={bar(call.kind === "tool" && call.run ? call.run : call)} />
50
+ </span>
51
+ <span className="wu-call-metric">{waited ? <>Waited for you {formatMillis(waited.end - waited.start)}</> : metric}</span>
52
+ </Button>
53
+ <Tooltip.Content placement="bottom" className="wu-card">
54
+ {call.kind === "model" ? <ModelCallCard call={call} /> : <ToolCallCard call={call} workId={workId} waited={waited} enabled={open} />}
55
+ </Tooltip.Content>
56
+ </Tooltip>;
57
+ }
58
+
59
+ function ModelCallCard({ call }: { call: ModelCall }) {
60
+ return <>
61
+ <p className="wu-card-title">Model call</p>
62
+ <p className="wu-muted">{call.model} · {formatClock(call.start)}</p>
63
+ <dl className="wu-facts">
64
+ <Fact label="Duration" value={formatMillis(call.end - call.start)} />
65
+ {call.ttftMs !== null && <Fact label="First token" value={formatMillis(call.ttftMs)} />}
66
+ {call.stopReason && <Fact label="Stop reason" value={call.stopReason} />}
67
+ </dl>
68
+ <dl className="wu-facts">{tokenCategories.map(category => <Fact key={category.key} label={category.label} value={call.usage[category.key].toLocaleString()} />)}</dl>
69
+ </>;
70
+ }
71
+
72
+ function ToolCallCard({ call, workId, waited, enabled }: { call: ToolCall; workId: string; waited: TimeRange | null; enabled: boolean }) {
73
+ // The call's summary is the message stream's, found by the call's identity.
74
+ const summary = useQuery({
75
+ queryKey: ["tool-call-summary", workId, call.callId],
76
+ queryFn: async ({ signal }) => {
77
+ const message = (await streamClient.listMessagesBySession({ agentWorkId: workId, toolCallIds: [call.callId], kinds: [UnifiedMessageKind.ACTION], limit: 1 }, { signal })).messages[0];
78
+ return message?.payload.case === "action" ? message.payload.value.summary : "";
79
+ },
80
+ enabled: enabled && Boolean(call.callId),
81
+ retry: false,
82
+ staleTime: Infinity,
83
+ });
84
+ return <>
85
+ <p className="wu-card-title">{call.name}</p>
86
+ <p className="wu-muted">{[call.source, formatClock(call.start)].filter(Boolean).join(" · ")}</p>
87
+ {summary.data && <p className="wu-summary">{summary.data}</p>}
88
+ <dl className="wu-facts">
89
+ {waited && <Fact label="Waited for you" value={formatMillis(waited.end - waited.start)} />}
90
+ <Fact label="Ran" value={formatMillis(call.run ? call.run.end - call.run.start : call.end - call.start)} />
91
+ {call.result && <Fact label="Result" value={call.result === "succeeded" ? "Succeeded" : "Failed"} />}
92
+ </dl>
93
+ </>;
94
+ }
95
+
96
+ function Fact({ label, value }: { label: string; value: string }) {
97
+ return <div><dt>{label}</dt><dd>{value}</dd></div>;
98
+ }
@@ -0,0 +1,170 @@
1
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
2
+ import type { Span } from "@talgent/workbench-contracts/trace/public/v1/trace_pb";
3
+ import { addTokenUsage, emptyTokenUsage, tokenUsageFromAttributes, type TokenUsage } from "../../../lib/token-usage";
4
+
5
+ export type TimeRange = { start: number; end: number };
6
+
7
+ export type ModelCall = TimeRange & {
8
+ kind: "model"; key: string; model: string; ttftMs: number | null; stopReason: string; usage: TokenUsage;
9
+ };
10
+
11
+ /** What kind of tool a call used, which its bar's colour tells apart. */
12
+ export type ToolFamily = "tool" | "mcp" | "agent";
13
+
14
+ export type ToolCall = TimeRange & {
15
+ kind: "tool"; key: string; callId: string; name: string; source: string; family: ToolFamily;
16
+ /** Time spent waiting on the person, from the blocked-on-user evidence. */
17
+ wait: TimeRange | null;
18
+ run: TimeRange | null;
19
+ result: "succeeded" | "failed" | null;
20
+ };
21
+
22
+ export type Call = ModelCall | ToolCall;
23
+
24
+ export type Turn = TimeRange & {
25
+ key: string;
26
+ /** No interaction span yet: the newest Turn, still running or cut off. */
27
+ open: boolean;
28
+ /** Every model call in the Turn, delegated ones included. */
29
+ usage: TokenUsage;
30
+ /** The Turn's own calls, oldest first. */
31
+ calls: Call[];
32
+ };
33
+
34
+ const interaction = "claude_code.interaction";
35
+ const modelRequest = "claude_code.llm_request";
36
+ const toolCall = "claude_code.tool";
37
+ const waitSpan = "claude_code.tool.blocked_on_user";
38
+ const executionSpan = "claude_code.tool.execution";
39
+
40
+ const keyOf = (span: Span) => `${span.traceId}:${span.spanId}`;
41
+ const parentKeyOf = (span: Span) => `${span.traceId}:${span.parentSpanId}`;
42
+
43
+ export function spanRange(span: Span): TimeRange {
44
+ const start = span.startTime ? timestampDate(span.startTime).getTime() : 0;
45
+ return { start, end: start + Number(span.durationNanos) / 1_000_000 };
46
+ }
47
+
48
+ /**
49
+ * Groups loaded spans into Turns, newest first. A Turn is an interaction span
50
+ * and its descendants. Claude Code exports the interaction span only when the
51
+ * Turn ends, so the newest calls without one form an open Turn. Older calls
52
+ * without one belong to a Turn on a page not loaded yet; they are left out
53
+ * until it is, unless no earlier page remains.
54
+ */
55
+ export function buildTurns(spans: Span[], hasEarlier: boolean): Turn[] {
56
+ const byKey = new Map(spans.map(span => [keyOf(span), span]));
57
+ const groups = new Map<string, { root: Span | null; spans: Span[] }>();
58
+ const rootCache = new Map<string, string>();
59
+ const rootOf = (span: Span, seen = new Set<string>()): string => {
60
+ const key = keyOf(span);
61
+ const cached = rootCache.get(key);
62
+ if (cached) return cached;
63
+ const parent = byKey.get(parentKeyOf(span));
64
+ const root = span.name === interaction ? key : parent && !seen.has(key) ? rootOf(parent, seen.add(key)) : parentKeyOf(span);
65
+ rootCache.set(key, root);
66
+ return root;
67
+ };
68
+ for (const span of byKey.values()) {
69
+ const root = rootOf(span);
70
+ const group = groups.get(root) ?? { root: null, spans: [] };
71
+ if (keyOf(span) === root) group.root = span;
72
+ else group.spans.push(span);
73
+ groups.set(root, group);
74
+ }
75
+
76
+ const newestInteraction = Math.max(...[...groups.values()].flatMap(group => group.root ? [spanRange(group.root).start] : []));
77
+ const rangeOf = (group: { root: Span | null; spans: Span[] }) => {
78
+ if (group.root) return spanRange(group.root);
79
+ const ranges = group.spans.map(spanRange);
80
+ return { start: Math.min(...ranges.map(item => item.start)), end: Math.max(...ranges.map(item => item.end)) };
81
+ };
82
+ // Only the newest group without an interaction span, after every finished Turn, can be the open one.
83
+ const openKey = [...groups].filter(([, group]) => !group.root && rangeOf(group).start > newestInteraction)
84
+ .sort((left, right) => rangeOf(right[1]).start - rangeOf(left[1]).start)[0]?.[0];
85
+ const turns: Turn[] = [];
86
+ for (const [rootKey, group] of groups) {
87
+ const range = rangeOf(group);
88
+ const open = rootKey === openKey;
89
+ if (!group.root && !open && hasEarlier) continue;
90
+ turns.push({ key: rootKey, ...range, open, usage: turnUsage(group.spans), calls: turnCalls(rootKey, group.spans) });
91
+ }
92
+ return turns.sort((left, right) => right.start - left.start);
93
+ }
94
+
95
+ function turnUsage(spans: Span[]): TokenUsage {
96
+ return spans.filter(span => span.name === modelRequest).reduce((usage, span) => addTokenUsage(usage, tokenUsageFromAttributes(span.attributes)), emptyTokenUsage());
97
+ }
98
+
99
+ function turnCalls(rootKey: string, spans: Span[]): Call[] {
100
+ const children = new Map<string, Span[]>();
101
+ for (const span of spans) children.set(parentKeyOf(span), [...(children.get(parentKeyOf(span)) ?? []), span]);
102
+ const calls: Call[] = [];
103
+ for (const span of children.get(rootKey) ?? []) {
104
+ const range = spanRange(span);
105
+ if (span.name === modelRequest) {
106
+ const ttft = Number(span.attributes.ttft_ms);
107
+ calls.push({ kind: "model", key: keyOf(span), ...range, model: span.attributes.model || "Unknown model", ttftMs: span.attributes.ttft_ms && Number.isFinite(ttft) ? ttft : null, stopReason: span.attributes.stop_reason ?? "", usage: tokenUsageFromAttributes(span.attributes) });
108
+ }
109
+ if (span.name === toolCall) {
110
+ // One call is one row: its wait and execution are its child spans.
111
+ const parts = children.get(keyOf(span)) ?? [];
112
+ const wait = parts.find(part => part.name === waitSpan);
113
+ const execution = parts.find(part => part.name === executionSpan);
114
+ const success = execution?.attributes.success;
115
+ calls.push({
116
+ kind: "tool", key: keyOf(span), ...range, callId: span.callId || span.attributes.tool_use_id || "",
117
+ ...toolName(span.attributes),
118
+ wait: wait ? spanRange(wait) : null, run: execution ? spanRange(execution) : null,
119
+ result: success === "true" ? "succeeded" : success === "false" || execution?.attributes.error ? "failed" : null,
120
+ });
121
+ }
122
+ }
123
+ return calls.sort((left, right) => left.start - right.start);
124
+ }
125
+
126
+ /** MCP tools read by their own name with their server as the source. */
127
+ export function toolName(attributes: Record<string, string>): { name: string; source: string; family: ToolFamily } {
128
+ const tool = attributes.tool_name || "Unknown tool";
129
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(tool);
130
+ if (mcp) return { name: mcp[2], source: mcp[1], family: "mcp" };
131
+ if (tool === "mcp_tool") return { name: attributes.mcp_tool_name || "MCP tool", source: attributes.mcp_server_name ?? "", family: "mcp" };
132
+ if (tool === "Skill") return { name: "Skill", source: attributes.skill_name ?? "", family: "tool" };
133
+ if (tool === "Agent" || tool === "Task") return { name: tool, source: attributes.subagent_type ?? "", family: "agent" };
134
+ return { name: tool, source: "", family: "tool" };
135
+ }
136
+
137
+ /** Waits longer than this fold into an axis break. */
138
+ export const foldedWaitMillis = 60_000;
139
+ const breakWidth = 3;
140
+
141
+ export type TurnAxis = { position: (time: number) => number; breaks: number[] };
142
+
143
+ /** A Turn's time axis in percent, with each long wait folded to a short break
144
+ * so that it does not flatten every other call. */
145
+ export function turnAxis(turn: TimeRange, calls: Call[]): TurnAxis {
146
+ const folds = calls.flatMap(call => call.kind === "tool" && call.wait && call.wait.end - call.wait.start > foldedWaitMillis ? [call.wait] : [])
147
+ .sort((left, right) => left.start - right.start)
148
+ .reduce<TimeRange[]>((merged, wait) => {
149
+ const last = merged.at(-1);
150
+ if (last && wait.start <= last.end) last.end = Math.max(last.end, wait.end);
151
+ else merged.push({ ...wait });
152
+ return merged;
153
+ }, []);
154
+ const folded = folds.reduce((sum, fold) => sum + fold.end - fold.start, 0);
155
+ const visible = Math.max(turn.end - turn.start - folded, 1);
156
+ const scale = (100 - folds.length * breakWidth) / visible;
157
+ const position = (time: number) => {
158
+ const at = Math.min(Math.max(time, turn.start), turn.end);
159
+ let offset = 0;
160
+ let elapsed = at - turn.start;
161
+ for (const fold of folds) {
162
+ if (at <= fold.start) break;
163
+ const inside = Math.min(at, fold.end) - fold.start;
164
+ elapsed -= inside;
165
+ offset += breakWidth * inside / (fold.end - fold.start);
166
+ }
167
+ return Math.min(100, elapsed * scale + offset);
168
+ };
169
+ return { position, breaks: folds.map(fold => position(fold.start) + breakWidth / 2) };
170
+ }
@@ -0,0 +1,36 @@
1
+ import { timestampDate, type Timestamp } from "@bufbuild/protobuf/wkt";
2
+ import { useAgentWork } from "../../../lib/agent-work-service";
3
+
4
+ export type WorkUsageStatus = { state: string; waiting: boolean; updatedAt: Date | null };
5
+
6
+ export function workUsageStatus(work: { state: string; waiting: boolean; updatedAt?: Timestamp } | undefined): WorkUsageStatus | null {
7
+ return work ? { state: work.state, waiting: work.waiting, updatedAt: work.updatedAt ? timestampDate(work.updatedAt) : null } : null;
8
+ }
9
+
10
+ /** The Work's own state, the authority on whether it works (AgentWork), as
11
+ * its owner reads it. */
12
+ export function useOwnWorkUsageStatus(workId: string): WorkUsageStatus | null {
13
+ return workUsageStatus(useAgentWork(workId).data?.work);
14
+ }
15
+
16
+ export function formatMillis(millis: number): string {
17
+ if (millis < 1000) return `${Math.round(millis)}ms`;
18
+ const seconds = millis / 1000;
19
+ if (seconds < 60) return `${seconds.toFixed(1).replace(/\.0$/, "")}s`;
20
+ const minutes = Math.floor(seconds / 60);
21
+ if (minutes < 60) return `${minutes}m ${String(Math.round(seconds % 60)).padStart(2, "0")}s`;
22
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
23
+ }
24
+
25
+ export function formatClock(time: number, now = Date.now()): string {
26
+ const date = new Date(time);
27
+ const clock = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
28
+ return new Date(now).toDateString() === date.toDateString() ? clock : `${date.toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${clock}`;
29
+ }
30
+
31
+ export function formatAgo(millis: number): string {
32
+ if (millis < 60_000) return `${Math.max(0, Math.round(millis / 1000))}s ago`;
33
+ if (millis < 3_600_000) return `${Math.floor(millis / 60_000)}m ago`;
34
+ if (millis < 86_400_000) return `${Math.floor(millis / 3_600_000)}h ago`;
35
+ return `${Math.floor(millis / 86_400_000)}d ago`;
36
+ }
@@ -0,0 +1,122 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Alert, Button, Chip, Disclosure, Skeleton, Tooltip } from "@heroui/react";
4
+ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
5
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
6
+ import { Download, RefreshCw } from "lucide-react";
7
+ import type { Span } from "@talgent/workbench-contracts/trace/public/v1/trace_pb";
8
+ import { traceClient } from "../../../lib/client";
9
+ import { formatCompactTokenCount, traceMetricKinds } from "../../../lib/trace-view-model";
10
+ import { cacheHitRate, formatHitRate, tokenCategories, tokenTotal, tokenUsageFromMeasurements } from "../../../lib/token-usage";
11
+ import { workMetricsWindow } from "../../../lib/use-work-trace";
12
+ import { workPresentation } from "../../../lib/work-state-presentation";
13
+ import { buildTurns, spanRange, toolName, type Turn } from "./turns";
14
+ import { TurnTimeline } from "./turn-timeline";
15
+ import { formatAgo, formatClock, formatMillis, type WorkUsageStatus } from "./work-status";
16
+ import "./work-usage.css";
17
+
18
+ const refreshMillis = 10_000;
19
+ const pageSize = 500;
20
+
21
+ /** A Work's Token Usage: its totals, whether it is working, and its Turns
22
+ * newest first. Native evidence stays out of view; the download keeps it.
23
+ * `status` is the Work's state as the caller may read it; `projectId` is
24
+ * empty for a Work without a Project. */
25
+ export function WorkUsageReader({ projectId, agentWorkId, createdAt = null, status, toolbar }: {
26
+ projectId: string; agentWorkId: string; createdAt?: Date | null; status: WorkUsageStatus | null; toolbar?: HTMLElement | null;
27
+ }) {
28
+ const live = status?.state === "working" || status?.state === "starting";
29
+ const now = useNow(refreshMillis);
30
+ const spans = useInfiniteQuery({
31
+ queryKey: ["work-usage-spans", projectId, agentWorkId], initialPageParam: "",
32
+ queryFn: ({ pageParam, signal }) => traceClient.listSpansBySession({ projectId, agentWorkId, limit: pageSize, afterCursor: pageParam, newestFirst: true }, { signal }),
33
+ getNextPageParam: page => page.nextCursor || undefined,
34
+ refetchInterval: live ? refreshMillis : false, retry: false, refetchOnWindowFocus: false,
35
+ });
36
+ const metrics = useQuery({
37
+ queryKey: ["trace-metrics", projectId, agentWorkId, createdAt?.getTime() ?? 0],
38
+ queryFn: ({ signal }) => {
39
+ const range = workMetricsWindow(new Date(), createdAt);
40
+ return traceClient.getSessionTraceMetrics({ projectId, agentWorkId, startTime: timestampFromDate(range.start), endTime: timestampFromDate(range.end), stepSeconds: range.stepSeconds, metricKinds: [...traceMetricKinds] }, { signal });
41
+ },
42
+ refetchInterval: live ? refreshMillis : false, retry: false, refetchOnWindowFocus: false,
43
+ });
44
+ const loaded = useMemo(() => spans.data?.pages.flatMap(page => page.spans) ?? [], [spans.data]);
45
+ const turns = useMemo(() => buildTurns(loaded, Boolean(spans.hasNextPage)), [loaded, spans.hasNextPage]);
46
+ const refresh = () => { void spans.refetch(); void metrics.refetch(); };
47
+ const download = () => {
48
+ const body = JSON.stringify({ agentWorkId, spans: loaded, hasEarlierSpans: Boolean(spans.hasNextPage), metrics: metrics.data ?? null }, (_, value) => typeof value === "bigint" ? value.toString() : value, 2);
49
+ const url = URL.createObjectURL(new Blob([body], { type: "application/json" }));
50
+ const link = document.createElement("a"); link.href = url; link.download = `${agentWorkId}-usage.json`; link.click();
51
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
52
+ };
53
+
54
+ return <div className="pr-document wu-reader">
55
+ {toolbar && createPortal(<>
56
+ <Tooltip><Button isIconOnly size="sm" variant="ghost" aria-label="Refresh usage" isPending={spans.isFetching || metrics.isFetching} onPress={refresh}><RefreshCw size={16} /></Button><Tooltip.Content>Refresh usage</Tooltip.Content></Tooltip>
57
+ <Tooltip><Button isIconOnly size="sm" variant="ghost" aria-label="Download usage details" isDisabled={!spans.data && !metrics.data} onPress={download}><Download size={16} /></Button><Tooltip.Content>Download usage details</Tooltip.Content></Tooltip>
58
+ </>, toolbar)}
59
+ {metrics.isPending ? <Skeleton className="h-16 rounded-lg" aria-label="Loading token usage" />
60
+ : metrics.isError && !metrics.data ? <QueryError title="Token usage couldn't load." onRetry={() => void metrics.refetch()} />
61
+ : <UsageTotals totals={metrics.data?.totals ?? []} />}
62
+ <StatusLine status={status} spans={loaded} now={now} />
63
+ <h2 className="pr-section-title">Turns</h2>
64
+ {spans.isPending ? <Skeleton className="h-24 rounded-lg" aria-label="Loading Turns" />
65
+ : spans.isError && !spans.data ? <QueryError title="Turns couldn't load." onRetry={() => void spans.refetch()} />
66
+ : turns.length ? <ol className="wu-turns">{turns.map((turn, index) => <li key={turn.key}><TurnRow turn={turn} running={turn.open && live} defaultExpanded={index === 0} workId={agentWorkId} now={now} /></li>)}</ol>
67
+ : !spans.hasNextPage && <p className="wu-empty">No Turns to show.</p>}
68
+ {spans.hasNextPage && <Button size="sm" variant="secondary" className="wu-earlier" isPending={spans.isFetchingNextPage} onPress={() => void spans.fetchNextPage()}>Load earlier turns</Button>}
69
+ </div>;
70
+ }
71
+
72
+ function UsageTotals({ totals }: { totals: Array<{ metricKind: string; value: number }> }) {
73
+ const usage = tokenUsageFromMeasurements(totals);
74
+ const rate = cacheHitRate(usage);
75
+ return <section aria-label="Token usage"><dl className="pr-metrics wu-totals">
76
+ <div><dt>Total</dt><dd>{formatCompactTokenCount(tokenTotal(usage))}</dd></div>
77
+ {tokenCategories.map(category => <div key={category.key}><dt>{category.label}</dt><dd>{formatCompactTokenCount(usage[category.key])}</dd></div>)}
78
+ {rate !== null && <div><dt>Cache hit rate</dt><dd>{formatHitRate(rate)}</dd></div>}
79
+ </dl></section>;
80
+ }
81
+
82
+ /** Whether the Work is working comes from its state; trace activity only adds
83
+ * what it did last and when. */
84
+ function StatusLine({ status, spans, now }: { status: WorkUsageStatus | null; spans: Span[]; now: number }) {
85
+ const latest = spans.filter(span => span.name === "claude_code.llm_request" || span.name === "claude_code.tool")
86
+ .map(span => ({ span, end: spanRange(span).end })).sort((left, right) => right.end - left.end)[0];
87
+ const presentation = workPresentation(status ?? undefined);
88
+ const stopped = status && ["stopped", "failed", "closed"].includes(status.state) && status.updatedAt;
89
+ const activity = latest && <span>{latest.span.name === "claude_code.llm_request" ? `Model call by ${latest.span.attributes.model || "an unknown model"}` : toolName(latest.span.attributes).name} finished {formatClock(latest.end, now)} · {formatAgo(now - latest.end)}</span>;
90
+ return <p className="wu-status" role="status">
91
+ {presentation && <Chip size="sm" variant="soft" color={presentation.color}><Chip.Label>{presentation.label}</Chip.Label></Chip>}
92
+ {stopped ? <span>at {formatClock(stopped.getTime(), now)}</span> : status?.waiting ? null : activity}
93
+ </p>;
94
+ }
95
+
96
+ function TurnRow({ turn, running, defaultExpanded, workId, now }: { turn: Turn; running: boolean; defaultExpanded: boolean; workId: string; now: number }) {
97
+ const [expanded, setExpanded] = useState(defaultExpanded);
98
+ return <Disclosure isExpanded={expanded} onExpandedChange={setExpanded}>
99
+ <Disclosure.Heading>
100
+ <Button slot="trigger" variant="ghost" className="wu-turn">
101
+ <Disclosure.Indicator />
102
+ <span className="wu-turn-time"><span className="sr-only">Turn started </span>{formatClock(turn.start, now)}</span>
103
+ <span className="wu-turn-duration">{running ? <Chip size="sm" variant="soft" color="success"><Chip.Label>Running</Chip.Label></Chip> : formatMillis(turn.end - turn.start)}</span>
104
+ <span className="wu-turn-tokens">{formatCompactTokenCount(tokenTotal(turn.usage))}<span className="sr-only"> tokens</span></span>
105
+ </Button>
106
+ </Disclosure.Heading>
107
+ <Disclosure.Content>{expanded && <TurnTimeline turn={turn} workId={workId} now={now} />}</Disclosure.Content>
108
+ </Disclosure>;
109
+ }
110
+
111
+ function QueryError({ title, onRetry }: { title: string; onRetry: () => void }) {
112
+ return <Alert status="danger" role="alert"><Alert.Indicator /><Alert.Content><Alert.Title>{title}</Alert.Title></Alert.Content><Button size="sm" variant="secondary" onPress={onRetry}>Retry</Button></Alert>;
113
+ }
114
+
115
+ function useNow(intervalMillis: number) {
116
+ const [now, setNow] = useState(Date.now);
117
+ useEffect(() => {
118
+ const timer = setInterval(() => setNow(Date.now()), intervalMillis);
119
+ return () => clearInterval(timer);
120
+ }, [intervalMillis]);
121
+ return now;
122
+ }
@@ -0,0 +1,48 @@
1
+ /* Work Token Usage reader. Bars tell call kinds apart with
2
+ low-saturation hues blended into the surface, so both themes stay quiet. */
3
+ .wu-totals { margin-bottom: 16px; }
4
+ .wu-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
5
+ .wu-empty { padding-block: 16px; font-size: 13px; color: var(--muted); }
6
+ .wu-turns { display: grid; margin-top: 8px; border-top: 1px solid var(--separator); }
7
+ .wu-turns>li { border-bottom: 1px solid var(--separator); }
8
+ .wu-turn { width: 100%; justify-content: flex-start; gap: 12px; font-variant-numeric: tabular-nums; }
9
+ .wu-turn-time { flex: 1; text-align: start; font-weight: 500; }
10
+ .wu-turn-duration { min-width: 72px; text-align: end; color: var(--muted); }
11
+ .wu-turn-tokens { min-width: 56px; text-align: end; }
12
+ .wu-earlier { margin-top: 12px; }
13
+
14
+ .wu-timeline { padding: 4px 0 16px 28px; }
15
+ .wu-axis { position: relative; display: flex; justify-content: space-between; margin-left: 180px; padding-bottom: 6px; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
16
+ .wu-break { position: absolute; bottom: 0; width: 8px; height: 10px; transform: translateX(-50%) skewX(-20deg); border-inline: 1px solid var(--muted); background: var(--surface); }
17
+ .wu-calls { display: grid; gap: 2px; }
18
+ .wu-call { display: grid; width: 100%; height: auto; min-height: 30px; grid-template-columns: 172px minmax(0,1fr) 96px; gap: 8px; align-items: center; padding-inline: 4px; font-size: 12px; text-align: start; }
19
+ .wu-call-name { display: flex; gap: 6px; min-width: 0; }
20
+ .wu-call-name>span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
21
+ .wu-call-metric { text-align: end; color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
22
+ .wu-muted { color: var(--muted); }
23
+ .wu-track { position: relative; height: 12px; border-radius: 3px; background: var(--default); }
24
+ .wu-bar { position: absolute; top: 2px; height: 8px; border-radius: 2px; }
25
+ .wu-bar, .wu-bar-swatch { background: color-mix(in oklch,var(--wu-hue) 78%,var(--surface)); }
26
+ .wu-timeline [data-kind="model"] { --wu-hue: oklch(0.6 0.07 255); }
27
+ .wu-timeline [data-kind="tool"] { --wu-hue: oklch(0.62 0.06 170); }
28
+ .wu-timeline [data-kind="mcp"] { --wu-hue: oklch(0.6 0.07 305); }
29
+ .wu-timeline [data-kind="agent"] { --wu-hue: oklch(0.66 0.07 70); }
30
+ /* A wait on the person is hatched so that it is not read as work. */
31
+ :is(.wu-bar, .wu-bar-swatch)[data-kind="wait"] { background: repeating-linear-gradient(135deg,color-mix(in oklch,var(--warning) 70%,var(--surface)) 0 3px,transparent 3px 6px); }
32
+ .wu-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0 0 10px 180px; font-size: 11px; color: var(--muted); }
33
+ .wu-legend>li { display: inline-flex; align-items: center; gap: 6px; }
34
+ .wu-bar-swatch { display: block; width: 10px; height: 8px; border-radius: 2px; }
35
+
36
+ .wu-card { display: grid; gap: 6px; max-width: 300px; padding: 10px 12px; font-size: 12px; }
37
+ .wu-card-title { font-size: 13px; font-weight: 600; }
38
+ .wu-summary { overflow-wrap: anywhere; }
39
+ .wu-facts { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 6px 16px; }
40
+ .wu-facts dt { color: var(--muted); }
41
+ .wu-facts dd { font-weight: 500; font-variant-numeric: tabular-nums; }
42
+
43
+ @media (max-width: 740px) {
44
+ .wu-timeline { padding-left: 0; }
45
+ .wu-axis, .wu-legend { margin-left: 0; }
46
+ .wu-call { grid-template-columns: minmax(0,1fr) 88px; }
47
+ .wu-track { grid-column: 1 / -1; grid-row: 2; }
48
+ }
@@ -0,0 +1,44 @@
1
+ import { Button, Card, Typography } from "@heroui/react";
2
+ import { Code, ConnectError } from "@connectrpc/connect";
3
+ import { useContext, useSyncExternalStore, type ReactNode } from "react";
4
+ import { Navigate, useLocation } from "react-router-dom";
5
+ import { useCurrentUser } from "../../lib/account-service";
6
+ import { sessionEnded, signInPagePath, subscribeSessionEnd } from "../../lib/session";
7
+ import { EntryShellContext } from "../shell/entry-shell";
8
+
9
+ // No usable session on this device: sign in, then come back here.
10
+ function SignInRedirect() {
11
+ const location = useLocation();
12
+ const home = useContext(EntryShellContext)?.home;
13
+ return <Navigate replace to={signInPagePath(`${location.pathname}${location.search}${location.hash}`, home)} />;
14
+ }
15
+
16
+ export function AuthGate({ children }: { children: ReactNode }) {
17
+ const current = useCurrentUser();
18
+ const ended = useSyncExternalStore(subscribeSessionEnd, sessionEnded);
19
+
20
+ if (ended) {
21
+ return <SignInRedirect />;
22
+ }
23
+ if (current.isLoading) {
24
+ return <span className="sr-only" role="status">Loading profile…</span>;
25
+ }
26
+ if (current.isError) {
27
+ if (current.error instanceof ConnectError && current.error.code !== Code.Unauthenticated) {
28
+ return (
29
+ <div className="flex min-h-screen items-center justify-center p-6">
30
+ <Card className="w-full max-w-md" role="alert">
31
+ <Card.Header><Card.Title>Unable to load profile</Card.Title></Card.Header>
32
+ <Card.Content><Typography type="body-sm" color="muted">Please try again.</Typography></Card.Content>
33
+ <Card.Footer><Button variant="secondary" isPending={current.isFetching} onPress={() => { void current.refetch(); }}>Retry profile</Button></Card.Footer>
34
+ </Card>
35
+ </div>
36
+ );
37
+ }
38
+ return <SignInRedirect />;
39
+ }
40
+ if (!current.data?.user) {
41
+ return <SignInRedirect />;
42
+ }
43
+ return <>{children}</>;
44
+ }