@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,153 @@
1
+ import { Alert, Button, Spinner } from "@heroui/react";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { Download } from "lucide-react";
4
+ import { useEffect, useRef, useState, type ReactNode } from "react";
5
+ import { fileExtension, textFormat } from "./file-format";
6
+ import { decodeUtf8Prefix, formatBytes, refusalText, type PreviewVerdict } from "./preview-verdict";
7
+ import { SourcePreview, TextPreview } from "./text-preview";
8
+
9
+ type Shown = { text: string; blob: null } | { text: null; blob: Blob };
10
+
11
+ // While a Preview Copy is made the verdict is asked again this often.
12
+ const renderingPollMs = 2000;
13
+
14
+ // Workbooks render as printed pages, which may leave cells out.
15
+ const workbookExtensions = new Set(["xlsx", "xls", "ods"]);
16
+
17
+ /**
18
+ * One file shown as Storage's verdict says: directly, truncated, as its
19
+ * Preview Copy, or refused with its reason and the original to download.
20
+ * A file needing a Preview Copy asks for it on opening, then polls until it
21
+ * shows; closing or switching files stops polling, and a late answer never
22
+ * replaces the current file's. `verdictKey` identifies the file's current
23
+ * content; a new key asks again. `requestRender` is absent where no Preview
24
+ * Copy can be made.
25
+ */
26
+ export function FilePreview({ name, verdictKey, loadVerdict, requestRender, toolbar, onDownload }: {
27
+ name: string;
28
+ verdictKey: readonly unknown[];
29
+ loadVerdict: (signal: AbortSignal) => Promise<PreviewVerdict>;
30
+ requestRender?: () => Promise<PreviewVerdict>;
31
+ toolbar: HTMLElement | null;
32
+ onDownload: () => void;
33
+ }) {
34
+ const queryClient = useQueryClient();
35
+ const queryKey = ["file-preview-verdict", ...verdictKey];
36
+ const verdict = useQuery({
37
+ queryKey,
38
+ queryFn: ({ signal }) => loadVerdict(signal),
39
+ retry: false, gcTime: 0, refetchOnWindowFocus: false,
40
+ refetchInterval: query => query.state.data?.mode === "render" && query.state.data.rendering ? renderingPollMs : false,
41
+ });
42
+ const render = useMutation({
43
+ mutationFn: async () => {
44
+ if (!requestRender) throw new Error("No Preview Copy can be made here.");
45
+ return requestRender();
46
+ },
47
+ });
48
+ const ask = () => render.mutate(undefined, {
49
+ // Per-call callbacks never run after unmount, so a late answer is dropped.
50
+ onSuccess: answer => queryClient.setQueryData(queryKey, answer),
51
+ });
52
+ const firstAsk = verdict.data?.mode === "render" && !verdict.data.rendering && Boolean(requestRender) && render.isIdle;
53
+ useEffect(() => {
54
+ // The first opening asks once; after that only Retry asks again.
55
+ if (firstAsk) ask();
56
+ // ask is rebuilt each render; firstAsk alone decides when to call it.
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, [firstAsk]);
59
+ const readable = verdict.data && (verdict.data.mode === "direct" || verdict.data.mode === "truncated" || verdict.data.mode === "preview_copy") && verdict.data.url;
60
+ const shown = useQuery({
61
+ // The link reads once and expires, so its URL identifies this read.
62
+ queryKey: ["file-preview-content", verdict.data?.url],
63
+ enabled: Boolean(readable),
64
+ queryFn: ({ signal }) => readShown(verdict.data!, signal),
65
+ retry: false, gcTime: 0, staleTime: Infinity, refetchOnWindowFocus: false,
66
+ });
67
+ // A failed read is retried with a new link from a new verdict.
68
+ const retry = () => void verdict.refetch();
69
+
70
+ if (verdict.isPending) return <p className="sr-only" role="status">Loading preview…</p>;
71
+ if (verdict.isError) return <PreviewAlert status="danger" message="Could not load the preview." action={<Button size="sm" variant="secondary" onPress={retry}>Retry preview</Button>} />;
72
+ const { data } = verdict;
73
+ if (data.mode === "refused") {
74
+ return <PreviewAlert status="default" message={refusalText(data)} action={<DownloadOriginal onDownload={onDownload} />} />;
75
+ }
76
+ if (data.mode === "render") {
77
+ if (!requestRender) return <PreviewAlert status="default" message="Preview isn't available for this kind of file." action={<DownloadOriginal onDownload={onDownload} />} />;
78
+ if (data.rendering || render.isPending || render.isIdle) {
79
+ return <PreviewAlert status="default" indicator={<Spinner size="sm" />} message="Generating preview… It appears here when ready." action={<DownloadOriginal onDownload={onDownload} />} />;
80
+ }
81
+ // The rendering ended without a copy: a temporary failure, or the request failed.
82
+ return <PreviewAlert status="default" message="Preview isn't available right now."
83
+ action={<div className="pv-actions"><Button size="sm" variant="secondary" onPress={ask}>Retry</Button><DownloadOriginal onDownload={onDownload} /></div>} />;
84
+ }
85
+ return <div className="pr-file-document">
86
+ {shown.isPending && <p className="sr-only" role="status">Loading preview…</p>}
87
+ {shown.isError && <PreviewAlert status="danger" message="Could not load this file." action={<Button size="sm" variant="secondary" onPress={retry}>Retry preview</Button>} />}
88
+ {data.mode === "truncated" && <TruncationNotice name={name} verdict={data} />}
89
+ {data.mode === "preview_copy" && workbookExtensions.has(fileExtension(name) ?? "") && <PreviewAlert status="default" message="Previewed as printed pages; some cells may not appear. Download the original for the full workbook." />}
90
+ {shown.data && (shown.data.text !== null
91
+ ? <ShownText name={name} text={shown.data.text} truncated={data.mode === "truncated"} toolbar={toolbar} />
92
+ : <BinaryPreview key={shown.dataUpdatedAt} blob={shown.data.blob} name={name} image={data.display === "image"} onRetry={retry} />)}
93
+ </div>;
94
+ }
95
+
96
+ async function readShown(verdict: PreviewVerdict, signal: AbortSignal): Promise<Shown> {
97
+ const truncated = verdict.mode === "truncated";
98
+ const response = await fetch(verdict.url!, { signal, headers: truncated ? { Range: `bytes=0-${verdict.limitBytes - 1}` } : undefined });
99
+ if (!response.ok) throw new Error("Could not load this file.");
100
+ if (verdict.display === "text") {
101
+ const bytes = new Uint8Array(await response.arrayBuffer());
102
+ // A server ignoring Range still sends the whole file; show only the start.
103
+ return { text: decodeUtf8Prefix(truncated ? bytes.subarray(0, verdict.limitBytes) : bytes), blob: null };
104
+ }
105
+ // Fetch the authorized bytes before rendering: the content endpoint denies
106
+ // framing, and a link does not prove the read will succeed.
107
+ return { text: null, blob: new Blob([await response.blob()], { type: verdict.display === "pdf" ? "application/pdf" : response.headers.get("Content-Type") ?? "" }) };
108
+ }
109
+
110
+ function isMarkup(name: string) {
111
+ const kind = textFormat(name).kind;
112
+ return kind === "html" || kind === "svg";
113
+ }
114
+
115
+ // Truncated markup is never rendered: its source shows instead.
116
+ function ShownText({ name, text, truncated, toolbar }: { name: string; text: string; truncated: boolean; toolbar: HTMLElement | null }) {
117
+ return truncated && isMarkup(name) ? <SourcePreview path={name} content={text} /> : <TextPreview path={name} content={text} toolbar={toolbar} />;
118
+ }
119
+
120
+ function TruncationNotice({ name, verdict }: { name: string; verdict: PreviewVerdict }) {
121
+ const limit = formatBytes(verdict.limitBytes);
122
+ const message = isMarkup(name)
123
+ ? `This file is over ${limit}, too large to render. Showing the source of its first ${limit}.`
124
+ : `Showing the first ${limit} of this file.`;
125
+ return <PreviewAlert status="default" message={message} />;
126
+ }
127
+
128
+ function PreviewAlert({ status, message, action, indicator }: { status: "default" | "danger"; message: string; action?: ReactNode; indicator?: ReactNode }) {
129
+ return <Alert status={status} className="pv-alert" role={status === "danger" ? "alert" : "status"}>
130
+ <Alert.Indicator>{indicator}</Alert.Indicator>
131
+ <Alert.Content><Alert.Description>{message}</Alert.Description></Alert.Content>
132
+ {action}
133
+ </Alert>;
134
+ }
135
+
136
+ function DownloadOriginal({ onDownload }: { onDownload: () => void }) {
137
+ return <Button size="sm" variant="secondary" onPress={onDownload}><Download size={14} />Download original</Button>;
138
+ }
139
+
140
+ function BinaryPreview({ blob, name, image, onRetry }: { blob: Blob; name: string; image: boolean; onRetry: () => void }) {
141
+ const imageElement = useRef<HTMLImageElement>(null);
142
+ const frameElement = useRef<HTMLIFrameElement>(null);
143
+ const [failed, setFailed] = useState(false);
144
+ useEffect(() => {
145
+ const element = imageElement.current ?? frameElement.current;
146
+ if (!element) return;
147
+ const url = URL.createObjectURL(blob);
148
+ element.src = url;
149
+ return () => URL.revokeObjectURL(url);
150
+ }, [blob]);
151
+ if (failed) return <PreviewAlert status="danger" message="Could not display this file." action={<Button size="sm" variant="secondary" onPress={onRetry}>Retry preview</Button>} />;
152
+ return image ? <img ref={imageElement} alt={name} onError={() => setFailed(true)} /> : <iframe ref={frameElement} title={name} onError={() => setFailed(true)} />;
153
+ }
@@ -0,0 +1,44 @@
1
+ /** How Storage shows a file; the reading area follows it and nothing else. */
2
+ export type PreviewVerdict = {
3
+ mode: "refused" | "preview_copy" | "render" | "direct" | "truncated";
4
+ display: "text" | "pdf" | "image" | "none";
5
+ refusal?: "unsupported" | "structure_mismatch" | "encrypted" | "too_large" | "corrupt" | "converter_refused" | "rendering_too_large";
6
+ /** Shown length of a truncated file, or the limit a too large file exceeds. */
7
+ limitBytes: number;
8
+ /** One inline read of the shown bytes. */
9
+ url?: string;
10
+ /** With mode "render": a Preview Copy of the content is queued or being made. */
11
+ rendering: boolean;
12
+ };
13
+
14
+ export function formatBytes(bytes: number): string {
15
+ const mib = bytes / (1 << 20);
16
+ if (mib >= 1) return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`;
17
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KiB`;
18
+ return `${bytes} bytes`;
19
+ }
20
+
21
+ export function refusalText(verdict: PreviewVerdict): string {
22
+ switch (verdict.refusal) {
23
+ case "structure_mismatch": return "The content doesn't match the file type.";
24
+ case "encrypted": return "This file is encrypted.";
25
+ case "too_large": return `This file is over ${formatBytes(verdict.limitBytes)}, too large to preview.`;
26
+ case "corrupt": return "This file is damaged and can't be previewed.";
27
+ case "converter_refused": return "This file can't be converted for preview.";
28
+ case "rendering_too_large": return "The preview of this file came out too large to show.";
29
+ default: return "Preview isn't available for this kind of file.";
30
+ }
31
+ }
32
+
33
+ /** Cuts bytes at the last whole UTF-8 character, so a truncated read decodes cleanly. */
34
+ export function decodeUtf8Prefix(bytes: Uint8Array): string {
35
+ let end = bytes.length;
36
+ for (let back = 1; back <= 3 && back <= bytes.length; back++) {
37
+ const byte = bytes[bytes.length - back];
38
+ if ((byte & 0xc0) === 0x80) continue;
39
+ const length = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
40
+ if (length > back) end = bytes.length - back;
41
+ break;
42
+ }
43
+ return new TextDecoder().decode(bytes.subarray(0, end));
44
+ }
@@ -0,0 +1,19 @@
1
+ .pv-plain { margin: 0; padding: 12px 14px; border-radius: var(--radius); background: var(--surface-secondary); font-family: var(--font-mono); font-size: 13px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
2
+ .pv-inline-tools { display: flex; justify-content: flex-end; margin-bottom: 12px; }
3
+ .pv-svg { display: grid; gap: 12px; min-width: 0; }
4
+ .pv-svg > img { display: block; max-width: 100%; max-height: 70vh; width: auto; height: auto; margin-inline: auto; object-fit: contain; }
5
+ /* A previewed Markdown file reads as a document, not a card, with its
6
+ headings scaled below the reading area's own title. */
7
+ .pv-markdown-document { min-width: 0; max-width: 100%; overflow: auto; }
8
+ .pv-markdown-document h1 { font-size: 24px; }
9
+ .pv-markdown-document h2 { font-size: 17px; margin-top: 28px; }
10
+ .pv-markdown-document h3 { font-size: 15px; }
11
+ .pv-html { position: relative; min-width: 0; }
12
+ .pv-html > iframe { display: block; width: 100%; min-height: 100vh; border: 1px solid var(--separator); border-radius: var(--radius); background: var(--background); }
13
+ .pv-html > .button { position: absolute; top: 8px; right: 8px; }
14
+ .pv-diff { margin: 0; padding-block: 8px; border: 1px solid var(--separator); border-radius: var(--radius); background: var(--background); font-family: var(--font-mono); font-size: 12px; line-height: 20px; white-space: pre-wrap; overflow-wrap: anywhere; }
15
+ .pv-diff > span { display: block; padding-inline: 12px; color: var(--muted); }
16
+ .pv-diff > [data-diff-line="added"] { background: color-mix(in oklab, var(--success) 10%, transparent); color: var(--success); }
17
+ .pv-diff > [data-diff-line="removed"] { background: color-mix(in oklab, var(--danger) 10%, transparent); color: var(--danger); }
18
+ .pv-alert { margin-bottom: 16px; align-items: center; }
19
+ .pv-actions { display: flex; gap: 8px; }
@@ -0,0 +1,174 @@
1
+ import { Button, Disclosure, ToggleButton, ToggleButtonGroup, Tooltip } from "@heroui/react";
2
+ import { Maximize2 } from "lucide-react";
3
+ import { useEffect, useMemo, useRef, useState } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { CodeListing, Markdown } from "../markdown";
6
+ import { sourceLanguage, textFormat } from "./file-format";
7
+ import "./preview.css";
8
+
9
+ /**
10
+ * Above this many characters a listing is shown unhighlighted: Shiki's
11
+ * JavaScript engine tokenizes on the main thread and React renders one span
12
+ * per token, so a large file would freeze the page.
13
+ */
14
+ export const HIGHLIGHT_LIMIT = 100_000;
15
+
16
+ const PREVIEW_CSP = "default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; font-src data:; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none';";
17
+ const PREVIEW_RESIZE_MESSAGE_TYPE = "talgent:artifact-preview:resize";
18
+ const PREVIEW_RESIZE_BRIDGE = `<script>
19
+ (() => {
20
+ const messageType = "${PREVIEW_RESIZE_MESSAGE_TYPE}";
21
+ const report = () => {
22
+ const body = document.body;
23
+ const root = document.documentElement;
24
+ const height = Math.max(
25
+ body?.scrollHeight || 0,
26
+ body?.offsetHeight || 0,
27
+ root?.scrollHeight || 0,
28
+ root?.offsetHeight || 0
29
+ );
30
+ if (height > 0) {
31
+ window.parent.postMessage({ type: messageType, height: Math.ceil(height) }, "*");
32
+ }
33
+ };
34
+ window.addEventListener("load", report);
35
+ document.addEventListener("DOMContentLoaded", report);
36
+ try {
37
+ new ResizeObserver(report).observe(document.documentElement);
38
+ } catch {
39
+ window.setInterval(report, 1000);
40
+ }
41
+ window.setTimeout(report, 0);
42
+ })();
43
+ </script>`;
44
+
45
+ /**
46
+ * Text content shown by its path's extension. `toolbar` is the reading area's
47
+ * context-action slot, where a Markdown document puts its Rendered/Source switch.
48
+ */
49
+ export function TextPreview({ content, path, toolbar = null }: { content: string; path?: string; toolbar?: HTMLElement | null }) {
50
+ const format = textFormat(path);
51
+ switch (format.kind) {
52
+ case "markdown": return <MarkdownDocument content={content} toolbar={toolbar} />;
53
+ case "html": return <HtmlPreview content={content} />;
54
+ case "svg": return <SvgPreview content={content} name={path} />;
55
+ case "diff": return <DiffView content={content} />;
56
+ default: return <CodeView code={content} language={sourceLanguage(format)} />;
57
+ }
58
+ }
59
+
60
+ /** Source in the format's highlighting, as a partial read or a refused render shows it. */
61
+ export function SourcePreview({ content, path, startLine }: { content: string; path?: string; startLine?: number }) {
62
+ return <CodeView code={content} language={sourceLanguage(textFormat(path))} startLine={startLine} />;
63
+ }
64
+
65
+ /** Numbered, highlighted source; too long to highlight, it falls back to plain text. */
66
+ export function CodeView({ code, language, startLine = 1 }: { code: string; language: string; startLine?: number }) {
67
+ if (code.length > HIGHLIGHT_LIMIT) return <pre className="pv-plain" tabIndex={0} aria-label="Source">{code}</pre>;
68
+ return <CodeListing code={code} language={language} startLine={startLine} />;
69
+ }
70
+
71
+ type MarkdownMode = "rendered" | "source";
72
+
73
+ // A previewed Markdown file reads as a document, not a card.
74
+ function MarkdownDocument({ content, toolbar }: { content: string; toolbar: HTMLElement | null }) {
75
+ const [mode, setMode] = useState<MarkdownMode>("rendered");
76
+ const toggle = (
77
+ <ToggleButtonGroup aria-label="Markdown view" size="sm" selectionMode="single" disallowEmptySelection
78
+ selectedKeys={[mode]} onSelectionChange={keys => { const [next] = keys; if (next) setMode(next as MarkdownMode); }}>
79
+ <ToggleButton id="rendered">Rendered</ToggleButton>
80
+ <ToggleButton id="source"><ToggleButtonGroup.Separator />Source</ToggleButton>
81
+ </ToggleButtonGroup>
82
+ );
83
+ return <>
84
+ {toolbar ? createPortal(toggle, toolbar) : <div className="pv-inline-tools">{toggle}</div>}
85
+ {mode === "rendered"
86
+ ? <div className="pv-markdown-document"><Markdown>{content}</Markdown></div>
87
+ : <CodeView code={content} language="markdown" />}
88
+ </>;
89
+ }
90
+
91
+ /** An SVG shows as an image at its own proportions: as an image it runs no script and loads nothing. */
92
+ function SvgPreview({ content, name }: { content: string; name?: string }) {
93
+ const src = useMemo(() => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(content)}`, [content]);
94
+ return <div className="pv-svg">
95
+ <img src={src} alt={name ?? "SVG image"} />
96
+ <Disclosure>
97
+ <Disclosure.Heading>
98
+ <Button slot="trigger" size="sm" variant="ghost">View source<Disclosure.Indicator /></Button>
99
+ </Disclosure.Heading>
100
+ <Disclosure.Content><Disclosure.Body><CodeView code={content} language="xml" /></Disclosure.Body></Disclosure.Content>
101
+ </Disclosure>
102
+ </div>;
103
+ }
104
+
105
+ // The frame is sandboxed without same-origin, so only its own bridge script
106
+ // can report the document height.
107
+ function HtmlPreview({ content }: { content: string }) {
108
+ const iframeRef = useRef<HTMLIFrameElement>(null);
109
+ const [previewHeight, setPreviewHeight] = useState<number | null>(null);
110
+
111
+ useEffect(() => {
112
+ const onMessage = (event: MessageEvent) => {
113
+ if (event.source !== iframeRef.current?.contentWindow) return;
114
+ const data = event.data as { type?: unknown; height?: unknown } | null;
115
+ if (!data || typeof data !== "object" || data.type !== PREVIEW_RESIZE_MESSAGE_TYPE) return;
116
+ const height = Number(data.height);
117
+ if (Number.isFinite(height) && height > 0) setPreviewHeight(Math.ceil(height));
118
+ };
119
+ window.addEventListener("message", onMessage);
120
+ return () => window.removeEventListener("message", onMessage);
121
+ }, []);
122
+
123
+ return (
124
+ <div className="pv-html">
125
+ <iframe
126
+ allow="fullscreen"
127
+ allowFullScreen
128
+ ref={iframeRef}
129
+ referrerPolicy="no-referrer"
130
+ sandbox="allow-scripts"
131
+ srcDoc={previewDocument(content)}
132
+ style={previewHeight === null ? undefined : { height: previewHeight }}
133
+ title="HTML preview"
134
+ />
135
+ <Tooltip>
136
+ <Button isIconOnly size="sm" variant="secondary" aria-label="Open HTML preview fullscreen" onPress={() => void iframeRef.current?.requestFullscreen?.()}>
137
+ <Maximize2 size={16} />
138
+ </Button>
139
+ <Tooltip.Content>Open fullscreen</Tooltip.Content>
140
+ </Tooltip>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ /** Unified diff lines, added and removed lines marked. */
146
+ export function DiffView({ content }: { content: string }) {
147
+ return (
148
+ <pre className="pv-diff">
149
+ {content.split("\n").map((line, index) => (
150
+ <span data-diff-line={diffLineType(line)} key={index}>{line || " "}</span>
151
+ ))}
152
+ </pre>
153
+ );
154
+ }
155
+
156
+ function diffLineType(line: string): "added" | "removed" | "context" {
157
+ if (line.startsWith("+") && !line.startsWith("+++")) {
158
+ return "added";
159
+ }
160
+ if (line.startsWith("-") && !line.startsWith("---")) {
161
+ return "removed";
162
+ }
163
+ return "context";
164
+ }
165
+
166
+ function previewDocument(content: string): string {
167
+ const meta = `<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(PREVIEW_CSP)}">`;
168
+
169
+ return `<!doctype html><html><head>${meta}${PREVIEW_RESIZE_BRIDGE}</head><body>${content}</body></html>`;
170
+ }
171
+
172
+ function escapeAttribute(value: string): string {
173
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
174
+ }
@@ -0,0 +1,43 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useQueryClient } from "@tanstack/react-query";
3
+ import { useLocation, useNavigate } from "react-router-dom";
4
+ import { marketplaceQueryKey } from "../../lib/marketplace-catalog";
5
+ import { completeProviderAuthorization, providerName, takeProviderReturn } from "../../lib/marketplace-connections";
6
+ import { notify } from "../../lib/notifications";
7
+
8
+ // Completes a Provider authorization returning on the registered callback URL,
9
+ // then returns to the page that started it. The OAuth code is used once.
10
+ export function useProviderAuthorizationCallback() {
11
+ const handled = useRef(false);
12
+ const navigate = useNavigate();
13
+ const cache = useQueryClient();
14
+ const params = new URLSearchParams(useLocation().search);
15
+ const code = params.get("code") ?? "", state = params.get("state") ?? "";
16
+ // The Provider returns `error` instead of a code when the user declines.
17
+ const declined = Boolean(params.get("error") && state);
18
+ const pending = Boolean(code && state) || declined;
19
+ useEffect(() => {
20
+ if (!pending || handled.current) return;
21
+ handled.current = true;
22
+ const provider = "github";
23
+ const name = providerName(provider);
24
+ if (declined) {
25
+ notify(`provider:${provider}`, "warning", `${name} wasn’t connected`, "Authorization was cancelled. You can connect again at any time.");
26
+ navigate(takeProviderReturn(provider), { replace: true });
27
+ return;
28
+ }
29
+ notify(`provider:${provider}`, "default", `Connecting ${name}…`, undefined, { timeout: 0 });
30
+ completeProviderAuthorization(provider, code, state).then(({ authorization, returnTo }) => {
31
+ void cache.invalidateQueries({ queryKey: marketplaceQueryKey });
32
+ void cache.invalidateQueries({ queryKey: ["work-start-options"] });
33
+ // A new authorization reaches different repositories.
34
+ void cache.invalidateQueries({ queryKey: ["work-start-repositories"] });
35
+ notify(`provider:${provider}`, "success", `${name} connected`, `Signed in as ${authorization.providerLogin}. Works you start from now on can use it.`);
36
+ navigate(returnTo, { replace: true });
37
+ }, () => {
38
+ notify(`provider:${provider}`, "danger", `${name} couldn’t be connected`, "Start connecting again from the Provider page.");
39
+ navigate(`/marketplace/providers/${provider}`, { replace: true });
40
+ });
41
+ }, [pending, declined, code, state, navigate, cache]);
42
+ return pending;
43
+ }
@@ -0,0 +1,109 @@
1
+ import { useState, type FormEvent } from "react";
2
+ import { Alert, Button, Card, FieldError, Input, Label, Skeleton, TextField, ToggleButton, ToggleButtonGroup, type Selection } from "@heroui/react";
3
+ import { Check } from "lucide-react";
4
+ import { AgentIcon } from "../agent/agent-icon";
5
+ import { agentColors, agentGlyphs, type AgentColor } from "../../lib/agent-icon";
6
+ import { agentAppearanceErrorMessage, useChangeMyWorkAgentAppearance, useMyWorkAgent, type MyWorkAgent } from "../../lib/agent-appearance-service";
7
+
8
+ const MAX_NAME = 40;
9
+
10
+ // Profile › Your Work Agent: the person names their Work Agent and picks its
11
+ // icon. Every place the Agent appears shows what is saved here.
12
+ export function WorkAgentAppearance() {
13
+ const agent = useMyWorkAgent();
14
+ return (
15
+ <section className="mk-section" aria-labelledby="pf-agent">
16
+ <h3 id="pf-agent" className="mk-section-title">Your Work Agent</h3>
17
+ {agent.data ? <AppearanceEditor key={`${agent.data.agentId}:${agent.data.name}:${agent.data.icon.glyph}:${agent.data.icon.color}`} saved={agent.data} />
18
+ : agent.isError ? (
19
+ <Alert status="danger">
20
+ <Alert.Indicator />
21
+ <Alert.Content>
22
+ <Alert.Title>Your Work Agent could not be loaded.</Alert.Title>
23
+ <Alert.Description>Its name and icon stay as they were.</Alert.Description>
24
+ </Alert.Content>
25
+ <Button size="sm" variant="secondary" isPending={agent.isFetching} onPress={() => void agent.refetch()}>Retry</Button>
26
+ </Alert>
27
+ ) : (
28
+ <Card className="mk-group" aria-busy="true">
29
+ <Card.Content className="flex items-center gap-4 p-5">
30
+ <p className="sr-only" role="status">Loading your Work Agent…</p>
31
+ <Skeleton className="size-14 rounded-2xl" />
32
+ <div className="grid flex-1 gap-2"><Skeleton className="h-3 w-16 rounded" /><Skeleton className="h-9 rounded-lg" /></div>
33
+ </Card.Content>
34
+ </Card>
35
+ )}
36
+ </section>
37
+ );
38
+ }
39
+
40
+ function nameProblem(name: string) {
41
+ if (!name) return "Enter a name.";
42
+ if (Array.from(name).length > MAX_NAME) return `Use at most ${MAX_NAME} characters.`;
43
+ return "";
44
+ }
45
+
46
+ function AppearanceEditor({ saved }: { saved: MyWorkAgent }) {
47
+ const change = useChangeMyWorkAgentAppearance();
48
+ const [draft, setDraft] = useState(saved);
49
+ const name = draft.name.trim();
50
+ const problem = nameProblem(name);
51
+ const dirty = name !== saved.name || draft.icon.glyph !== saved.icon.glyph || draft.icon.color !== saved.icon.color;
52
+ const pick = (part: "glyph" | "color") => (keys: Selection) => {
53
+ const id = [...keys][0];
54
+ if (id !== undefined) setDraft(current => ({ ...current, icon: { ...current.icon, [part]: String(id) } }));
55
+ };
56
+ const save = (event: FormEvent) => {
57
+ event.preventDefault();
58
+ if (!dirty || problem || change.isPending) return;
59
+ change.mutate({ ...draft, name });
60
+ };
61
+ const discard = () => { change.reset(); setDraft(saved); };
62
+ return (
63
+ <Card className="mk-group">
64
+ <form onSubmit={save} aria-label="Your Work Agent">
65
+ <Card.Content className="grid gap-5 p-5">
66
+ <div className="flex items-start gap-4">
67
+ <AgentIcon icon={draft.icon} size={56} />
68
+ <TextField className="min-w-0 flex-1" value={draft.name} onChange={value => setDraft(current => ({ ...current, name: value }))} isInvalid={Boolean(problem)} isDisabled={change.isPending}>
69
+ <Label>Name</Label>
70
+ <Input />
71
+ <FieldError>{problem}</FieldError>
72
+ </TextField>
73
+ </div>
74
+ <div className="grid gap-2">
75
+ <span id="pf-agent-glyph" className="text-[13px] font-medium">Icon</span>
76
+ <ToggleButtonGroup aria-labelledby="pf-agent-glyph" selectionMode="single" disallowEmptySelection isDetached isDisabled={change.isPending} size="sm" className="flex-wrap justify-start"
77
+ selectedKeys={new Set([draft.icon.glyph])} onSelectionChange={pick("glyph")}>
78
+ {agentGlyphs.map(({ id, label, Icon }) => <ToggleButton key={id} id={id} isIconOnly aria-label={label} variant="ghost"><Icon size={16} aria-hidden="true" /></ToggleButton>)}
79
+ </ToggleButtonGroup>
80
+ </div>
81
+ <div className="grid gap-2">
82
+ <span id="pf-agent-color" className="text-[13px] font-medium">Color</span>
83
+ <ToggleButtonGroup aria-labelledby="pf-agent-color" selectionMode="single" disallowEmptySelection isDetached isDisabled={change.isPending} size="sm" className="flex-wrap justify-start"
84
+ selectedKeys={new Set([draft.icon.color])} onSelectionChange={pick("color")}>
85
+ {(Object.keys(agentColors) as AgentColor[]).map(color => (
86
+ <ToggleButton key={color} id={color} isIconOnly aria-label={color[0].toUpperCase() + color.slice(1)} variant="ghost">
87
+ <span aria-hidden="true" className="agent-color-swatch" style={{ background: agentColors[color] }}>{draft.icon.color === color && <Check size={12} strokeWidth={3} />}</span>
88
+ </ToggleButton>
89
+ ))}
90
+ </ToggleButtonGroup>
91
+ </div>
92
+ {change.isError && (
93
+ <Alert status="danger">
94
+ <Alert.Indicator />
95
+ <Alert.Content><Alert.Title>{agentAppearanceErrorMessage(change.error)}</Alert.Title></Alert.Content>
96
+ </Alert>
97
+ )}
98
+ <div className="flex flex-wrap items-center justify-between gap-3">
99
+ <p className="mk-muted min-w-[16rem] flex-1 text-[13px]">Shown wherever your Work Agent appears, including comments it wrote before.</p>
100
+ <div className="ml-auto flex gap-2">
101
+ <Button size="sm" variant="ghost" isDisabled={!dirty || change.isPending} onPress={discard}>Discard</Button>
102
+ <Button size="sm" type="submit" isDisabled={!dirty || Boolean(problem)} isPending={change.isPending}>{change.isPending ? "Saving" : "Save"}</Button>
103
+ </div>
104
+ </div>
105
+ </Card.Content>
106
+ </form>
107
+ </Card>
108
+ );
109
+ }
@@ -0,0 +1,76 @@
1
+ import { useState, type FormEvent } from "react";
2
+ import { Alert, Button, Card, FieldError, Label, Skeleton, TextArea, TextField, Tooltip } from "@heroui/react";
3
+ import { Info } from "lucide-react";
4
+ import { useMyWorkAgent } from "../../lib/agent-appearance-service";
5
+ import { MAX_SOUL, soulErrorMessage, useChangeWorkAgentSoul, useWorkAgentSoul } from "../../lib/agent-soul-service";
6
+
7
+ // Profile › Soul: the persona and working style of the person's own Work Agent.
8
+ // When it applies lives in the tooltip beside the title. A Work Agent that
9
+ // cannot be loaded is reported once, by Your Work Agent.
10
+ export function WorkAgentSoul() {
11
+ const agent = useMyWorkAgent();
12
+ const soul = useWorkAgentSoul(agent.data?.agentId);
13
+ if (agent.isError) return null;
14
+ return (
15
+ <section className="mk-section" aria-labelledby="pf-soul">
16
+ <div className="flex items-center gap-2">
17
+ <h3 id="pf-soul" className="mk-section-title">Soul</h3>
18
+ <Tooltip delay={200}>
19
+ <Tooltip.Trigger aria-label="About Soul" className="rounded-full text-muted"><Info size={15} aria-hidden="true" /></Tooltip.Trigger>
20
+ <Tooltip.Content>Every Work of your Work Agent takes it up at its next start. Running work is not interrupted, and platform rules and the agreements a Work follows still come first.</Tooltip.Content>
21
+ </Tooltip>
22
+ </div>
23
+ {soul.data !== undefined && agent.data ? <SoulEditor key={`${agent.data.agentId}:${soul.data}`} agentId={agent.data.agentId} saved={soul.data} />
24
+ : soul.isError ? (
25
+ <Alert status="danger">
26
+ <Alert.Indicator />
27
+ <Alert.Content><Alert.Title>Your Work Agent's Soul could not be loaded.</Alert.Title></Alert.Content>
28
+ <Button size="sm" variant="secondary" isPending={soul.isFetching} onPress={() => void soul.refetch()}>Retry</Button>
29
+ </Alert>
30
+ ) : (
31
+ <Card className="mk-group" aria-busy="true">
32
+ <Card.Content className="grid gap-3 p-5">
33
+ <p className="sr-only" role="status">Loading your Work Agent's Soul…</p>
34
+ <Skeleton className="h-24 rounded-lg" />
35
+ </Card.Content>
36
+ </Card>
37
+ )}
38
+ </section>
39
+ );
40
+ }
41
+
42
+ function SoulEditor({ agentId, saved }: { agentId: string; saved: string }) {
43
+ const change = useChangeWorkAgentSoul();
44
+ const [draft, setDraft] = useState(saved);
45
+ const soul = draft.trim();
46
+ const problem = Array.from(soul).length > MAX_SOUL ? `Use at most ${MAX_SOUL} characters.` : "";
47
+ const dirty = soul !== saved;
48
+ const save = (event: FormEvent) => {
49
+ event.preventDefault();
50
+ if (!dirty || problem || change.isPending) return;
51
+ change.mutate({ agentId, soul });
52
+ };
53
+ return (
54
+ <Card className="mk-group">
55
+ <form onSubmit={save} aria-label="Soul">
56
+ <Card.Content className="grid gap-4 p-5">
57
+ <TextField value={draft} onChange={setDraft} isInvalid={Boolean(problem)} isDisabled={change.isPending}>
58
+ <Label className="sr-only">Soul</Label>
59
+ <TextArea rows={5} placeholder="How your Work Agent should talk and work" />
60
+ <FieldError>{problem}</FieldError>
61
+ </TextField>
62
+ {change.isError && (
63
+ <Alert status="danger">
64
+ <Alert.Indicator />
65
+ <Alert.Content><Alert.Title>{soulErrorMessage(change.error)}</Alert.Title></Alert.Content>
66
+ </Alert>
67
+ )}
68
+ <div className="flex justify-end gap-2">
69
+ <Button size="sm" variant="ghost" isDisabled={!dirty || change.isPending} onPress={() => { change.reset(); setDraft(saved); }}>Discard</Button>
70
+ <Button size="sm" type="submit" isDisabled={!dirty || Boolean(problem)} isPending={change.isPending}>{change.isPending ? "Saving" : "Save"}</Button>
71
+ </div>
72
+ </Card.Content>
73
+ </form>
74
+ </Card>
75
+ );
76
+ }
@@ -0,0 +1,20 @@
1
+ import { useId, useState, type ReactNode } from "react";
2
+ import { Button } from "@heroui/react";
3
+ import { ChevronRight } from "lucide-react";
4
+ import "./rail.css";
5
+
6
+ // One collapsible section of a context rail: a heading that toggles it, an
7
+ // optional summary that stays readable while it is collapsed, and an optional
8
+ // action beside the heading.
9
+ export function RailSection({ title, summary, children, action, defaultOpen = true, open: controlledOpen, onOpenChange, className = "" }: { title: string; summary?: string; children: ReactNode; action?: ReactNode; defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; className?: string }) {
10
+ const [localOpen, setLocalOpen] = useState(defaultOpen);
11
+ const open = controlledOpen ?? localOpen;
12
+ const setOpen = onOpenChange ?? setLocalOpen;
13
+ const contentId = useId();
14
+ return <section className={`cr-section ${className}`} aria-label={title}>
15
+ <div className="cr-heading"><h3><Button variant="ghost" className="cr-section-trigger" aria-label={title} aria-describedby={summary ? `${contentId}-summary` : undefined} aria-expanded={open} aria-controls={contentId} onPress={() => setOpen(!open)}>
16
+ <span>{title}</span>{summary && <span id={`${contentId}-summary`} className="cr-summary">{summary}</span>}<ChevronRight size={14} className="cr-expand" aria-hidden="true" />
17
+ </Button></h3>{action}</div>
18
+ <div id={contentId} hidden={!open}>{children}</div>
19
+ </section>;
20
+ }