@tangle-network/ui 11.0.0 → 11.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 (53) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/{artifact-pane-DvJyPWV4.d.ts → artifact-pane-DpuWcAiF.d.ts} +10 -1
  3. package/dist/chat.d.ts +8 -2
  4. package/dist/chat.js +5 -5
  5. package/dist/{chunk-52Y3FMFI.js → chunk-3M7BPSSW.js} +1 -1
  6. package/dist/chunk-4ORVXSMU.js +507 -0
  7. package/dist/{chunk-LELGOLFV.js → chunk-GGDYWMFF.js} +15 -7
  8. package/dist/{chunk-CCM2KRQH.js → chunk-HW4ROISY.js} +1 -1
  9. package/dist/{chunk-KBOD47AW.js → chunk-J3YZIQNI.js} +2 -2
  10. package/dist/{chunk-CSAIKY36.js → chunk-LTEKI7YJ.js} +12 -7
  11. package/dist/{chunk-KHO7DYXH.js → chunk-OTZWQJ5W.js} +107 -72
  12. package/dist/{chunk-FJBTCTZM.js → chunk-P5F43A65.js} +1 -1
  13. package/dist/{chunk-SZ44QDA6.js → chunk-RADUPP44.js} +12 -5
  14. package/dist/{chunk-HHYUPAOJ.js → chunk-SRAKCVPO.js} +49 -81
  15. package/dist/{document-editor-pane-DyDEX_Zm.d.ts → document-editor-pane-C3cYEDWZ.d.ts} +2 -2
  16. package/dist/{document-editor-pane-WCTA3ZOE.js → document-editor-pane-OQNGM4RJ.js} +3 -3
  17. package/dist/editor.d.ts +3 -3
  18. package/dist/editor.js +3 -3
  19. package/dist/files.d.ts +5 -3
  20. package/dist/files.js +3 -3
  21. package/dist/hooks.js +4 -4
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +9 -9
  24. package/dist/markdown.js +1 -1
  25. package/dist/openui.js +2 -2
  26. package/dist/primitives.d.ts +1 -1
  27. package/dist/primitives.js +1 -1
  28. package/dist/run.d.ts +4 -3
  29. package/dist/run.js +5 -5
  30. package/dist/sdk-hooks.js +4 -4
  31. package/package.json +1 -1
  32. package/src/chat/agent-timeline.stories.tsx +11 -0
  33. package/src/chat/agent-timeline.test.tsx +34 -0
  34. package/src/chat/agent-timeline.tsx +65 -12
  35. package/src/chat/chat-container.tsx +5 -0
  36. package/src/editor/document-editor-pane.stories.tsx +74 -0
  37. package/src/editor/document-editor-pane.tsx +10 -3
  38. package/src/files/file-artifact-pane.tsx +14 -2
  39. package/src/files/file-preview.stories.tsx +22 -1
  40. package/src/files/file-preview.tsx +2 -2
  41. package/src/markdown/markdown.stories.tsx +55 -1
  42. package/src/markdown/markdown.test.tsx +23 -0
  43. package/src/markdown/markdown.tsx +1 -1
  44. package/src/primitives/artifact-pane.tsx +52 -26
  45. package/src/run/expandable-content.test.tsx +52 -0
  46. package/src/run/expandable-content.tsx +73 -0
  47. package/src/run/expanded-tool-detail.tsx +17 -12
  48. package/src/run/inline-thinking-item.stories.tsx +30 -1
  49. package/src/run/inline-thinking-item.tsx +34 -70
  50. package/src/run/inline-tool-item.tsx +30 -116
  51. package/src/run/run-row-shell.test.tsx +38 -0
  52. package/src/run/run-row-shell.tsx +191 -0
  53. package/dist/chunk-INMWNFAS.js +0 -369
@@ -126,6 +126,40 @@ describe("AgentTimeline tool actions", () => {
126
126
  ).toBeInTheDocument()
127
127
  })
128
128
 
129
+ it("renders every row and no toggle when collapseAfter is unset", () => {
130
+ const items: AgentTimelineItem[] = Array.from({ length: 5 }, (_, i) => ({
131
+ id: `s-${i}`,
132
+ kind: "status",
133
+ label: `Step ${i}`,
134
+ }))
135
+ render(<AgentTimeline items={items} />)
136
+ expect(screen.getByText("Step 4")).toBeInTheDocument()
137
+ expect(
138
+ screen.queryByRole("button", { name: /more step/i }),
139
+ ).not.toBeInTheDocument()
140
+ })
141
+
142
+ it("collapses to the first N spine rows behind a toggle and reveals the rest", async () => {
143
+ const user = userEvent.setup()
144
+ const items: AgentTimelineItem[] = Array.from({ length: 5 }, (_, i) => ({
145
+ id: `s-${i}`,
146
+ kind: "status",
147
+ label: `Step ${i}`,
148
+ }))
149
+ render(<AgentTimeline items={items} collapseAfter={2} />)
150
+
151
+ // First 2 visible, the other 3 hidden behind the toggle
152
+ expect(screen.getByText("Step 0")).toBeInTheDocument()
153
+ expect(screen.getByText("Step 1")).toBeInTheDocument()
154
+ expect(screen.queryByText("Step 4")).not.toBeInTheDocument()
155
+
156
+ const toggle = screen.getByRole("button", { name: /show 3 more steps/i })
157
+ await user.click(toggle)
158
+
159
+ expect(screen.getByText("Step 4")).toBeInTheDocument()
160
+ expect(screen.getByRole("button", { name: /show less/i })).toBeInTheDocument()
161
+ })
162
+
129
163
  it("renders the source part's real input in the expanded detail", async () => {
130
164
  const user = userEvent.setup()
131
165
  const probePart: ToolPart = {
@@ -1,4 +1,4 @@
1
- import { type KeyboardEvent, type ReactNode } from "react";
1
+ import { useState, type KeyboardEvent, type ReactNode } from "react";
2
2
  import {
3
3
  AlertTriangle,
4
4
  CheckCircle2,
@@ -88,6 +88,9 @@ export interface AgentTimelineProps {
88
88
  /** Optional actions rendered beside each tool item (e.g. "open in artifacts").
89
89
  * Receives the source tool part carried on the item. */
90
90
  renderToolActions?: (part: ToolPart) => ReactNode;
91
+ /** When set, collapse the timeline to the first N spine rows behind a
92
+ * "Show N more steps" toggle. Omit to always show every row. */
93
+ collapseAfter?: number;
91
94
  }
92
95
 
93
96
  const TONE_STYLES: Record<AgentTimelineTone, { dot: string; card: string; text: string; icon: typeof Info }> = {
@@ -130,17 +133,24 @@ function formatTime(date: Date): string {
130
133
  interface AgentTimelineRowProps {
131
134
  isLast: boolean;
132
135
  accentClassName: string;
136
+ /** Dot top-offset. Defaults to aligning with the first text line; card rows
137
+ * pass CARD_ROW_DOT to center the dot on the card header instead. */
138
+ dotClassName?: string;
133
139
  children: ReactNode;
134
140
  }
135
141
 
136
- function AgentTimelineRow({ isLast, accentClassName, children }: AgentTimelineRowProps) {
142
+ // Centers the dot on a RunRowShell card header (2.5rem: h-6 badge + py-2),
143
+ // so tool and reasoning rows read as dot-aligned rather than top-anchored.
144
+ const CARD_ROW_DOT = "mt-[calc((2.5rem-var(--timeline-dot-size))/2)]";
145
+
146
+ function AgentTimelineRow({ isLast, accentClassName, dotClassName = "mt-2", children }: AgentTimelineRowProps) {
137
147
  return (
138
148
  <div className="grid grid-cols-[1.25rem_minmax(0,1fr)] gap-x-4">
139
149
  <div className="relative flex justify-center">
140
150
  {!isLast && (
141
151
  <span className="absolute top-4 bottom-[-0.75rem] left-1/2 w-px -translate-x-1/2 bg-border" />
142
152
  )}
143
- <span className={cn("relative mt-2 h-[var(--timeline-dot-size)] w-[var(--timeline-dot-size)] rounded-full ring-4 ring-[var(--bg-root)]", accentClassName)} />
153
+ <span className={cn("relative h-[var(--timeline-dot-size)] w-[var(--timeline-dot-size)] rounded-full ring-4 ring-[var(--bg-root)]", dotClassName, accentClassName)} />
144
154
  </div>
145
155
  <div className="min-w-0 pb-3">{children}</div>
146
156
  </div>
@@ -245,7 +255,10 @@ export function AgentTimeline({
245
255
  emptyState,
246
256
  className,
247
257
  renderToolActions,
258
+ collapseAfter,
248
259
  }: AgentTimelineProps) {
260
+ const [expanded, setExpanded] = useState(false);
261
+
249
262
  if (items.length === 0 && !isThinking) {
250
263
  return emptyState ? (
251
264
  <div className={cn("flex h-full items-center justify-center p-4", className)}>
@@ -258,13 +271,41 @@ export function AgentTimeline({
258
271
  ? [...items, { id: "__thinking__", kind: "custom", content: <ThinkingIndicator /> }]
259
272
  : items;
260
273
 
261
- // Determine which items participate in the timeline connector (non-user-message items)
262
- // User messages are rendered outside the timeline grid
263
- const timelineItems = renderedItems.filter((item) => !(item.kind === "message" && item.role === "user"));
274
+ const isUserMessage = (item: AgentTimelineItem) =>
275
+ item.kind === "message" && item.role === "user";
276
+
277
+ // Items on the vertical connector (user messages render off-spine).
278
+ const timelineItems = renderedItems.filter((item) => !isUserMessage(item));
279
+
280
+ const limit = collapseAfter ?? Infinity;
281
+ const collapsible = timelineItems.length > limit;
282
+ const collapsed = collapsible && !expanded;
283
+
284
+ // While collapsed, keep only the first `limit` spine rows (and any user
285
+ // messages that precede them); the rest hides behind the toggle.
286
+ let renderList = renderedItems;
287
+ let hiddenCount = 0;
288
+ if (collapsed) {
289
+ const visible: AgentTimelineItem[] = [];
290
+ let spineCount = 0;
291
+ for (const item of renderedItems) {
292
+ if (spineCount >= limit) break;
293
+ visible.push(item);
294
+ if (!isUserMessage(item)) spineCount += 1;
295
+ }
296
+ renderList = visible;
297
+ hiddenCount = timelineItems.length - limit;
298
+ }
299
+
300
+ const visibleSpine = renderList.filter((item) => !isUserMessage(item));
301
+ // When a toggle row follows, no real row is last — the toggle owns the tail.
302
+ const lastSpineItem = collapsible
303
+ ? undefined
304
+ : visibleSpine[visibleSpine.length - 1];
264
305
 
265
306
  return (
266
307
  <div className={cn("mx-auto w-full max-w-5xl px-4 py-4", className)}>
267
- {renderedItems.map((item, index) => {
308
+ {renderList.map((item) => {
268
309
  // User messages: right-aligned bubble, off-spine — needs its own vertical
269
310
  // rhythm so it doesn't sit flush against the status/tool/agent row below.
270
311
  if (item.kind === "message" && item.role === "user") {
@@ -275,8 +316,7 @@ export function AgentTimeline({
275
316
  );
276
317
  }
277
318
 
278
- const timelineIndex = timelineItems.indexOf(item);
279
- const isLast = timelineIndex === timelineItems.length - 1;
319
+ const isLast = item === lastSpineItem;
280
320
 
281
321
  if (item.kind === "message") {
282
322
  return (
@@ -288,7 +328,7 @@ export function AgentTimeline({
288
328
 
289
329
  if (item.kind === "tool") {
290
330
  return (
291
- <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
331
+ <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]" dotClassName={CARD_ROW_DOT}>
292
332
  <ToolCallStep
293
333
  type={item.call.type}
294
334
  label={item.call.label}
@@ -305,7 +345,7 @@ export function AgentTimeline({
305
345
 
306
346
  if (item.kind === "tool_group") {
307
347
  return (
308
- <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
348
+ <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]" dotClassName={CARD_ROW_DOT}>
309
349
  <ToolCallGroup title={item.title}>
310
350
  {item.calls.map((call, callIndex) => {
311
351
  const part = item.parts?.[callIndex];
@@ -354,11 +394,24 @@ export function AgentTimeline({
354
394
 
355
395
  // custom
356
396
  return (
357
- <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]">
397
+ <AgentTimelineRow key={item.id} isLast={isLast} accentClassName="bg-[var(--border-hover)]" dotClassName={CARD_ROW_DOT}>
358
398
  {(item as AgentTimelineCustomItem).content}
359
399
  </AgentTimelineRow>
360
400
  );
361
401
  })}
402
+ {collapsible ? (
403
+ <AgentTimelineRow isLast accentClassName="bg-[var(--border-hover)]">
404
+ <button
405
+ type="button"
406
+ onClick={() => setExpanded((value) => !value)}
407
+ className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
408
+ >
409
+ {collapsed
410
+ ? `Show ${hiddenCount} more step${hiddenCount === 1 ? "" : "s"}`
411
+ : "Show less"}
412
+ </button>
413
+ </AgentTimelineRow>
414
+ ) : null}
362
415
  </div>
363
416
  );
364
417
  }
@@ -63,6 +63,9 @@ export interface ChatContainerProps {
63
63
  /** Optional actions rendered beside individual tool items in the `timeline`
64
64
  * presentation, which has no run/message grouping (hence part-only). */
65
65
  renderTimelineToolActions?: (part: ToolPart) => ReactNode;
66
+ /** In the `timeline` presentation, collapse to the first N spine rows behind
67
+ * a "Show N more steps" toggle. Omit to always show every row. */
68
+ collapseTimelineAfter?: number;
66
69
  }
67
70
 
68
71
  const OPENUI_NODE_TYPES = new Set([
@@ -373,6 +376,7 @@ export const ChatContainer = memo(
373
376
  renderUserMessageActions,
374
377
  renderToolActions,
375
378
  renderTimelineToolActions,
379
+ collapseTimelineAfter,
376
380
  }: ChatContainerProps) => {
377
381
  const scrollRef = useRef<HTMLDivElement>(null);
378
382
 
@@ -414,6 +418,7 @@ export const ChatContainer = memo(
414
418
  items={timeline.items}
415
419
  isThinking={timeline.showThinking}
416
420
  renderToolActions={renderTimelineToolActions}
421
+ collapseAfter={collapseTimelineAfter}
417
422
  />
418
423
  </div>
419
424
  ) : (
@@ -0,0 +1,74 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { DocumentEditorPane } from './document-editor-pane'
3
+
4
+ const MARKDOWN_SOURCE = `# GTM Agent Repository Overview
5
+
6
+ Source: \`github.com/tangle-network/gtm-agent\`
7
+
8
+ ---
9
+
10
+ ## What It Is
11
+
12
+ GTM Agent is an AI-powered go-to-market workspace where founders get a sandboxed environment.
13
+
14
+ ## Architecture
15
+
16
+ | Layer | Owns | Location |
17
+ | --- | --- | --- |
18
+ | Engine | Stream normalization | \`@tangle-network/agent-app\` |
19
+ | Shell | Auth, billing, RBAC, vault | \`src/routes/**\` |
20
+
21
+ ## Tech Stack
22
+
23
+ - Frontend: Remix + Tailwind v4
24
+ - Database: SQLite + Drizzle ORM
25
+ - Sandbox: \`@tangle-network/sandbox\`
26
+
27
+ [Launch](https://gtm.tangle.tools)
28
+ `
29
+
30
+ const meta: Meta<typeof DocumentEditorPane> = {
31
+ title: 'Editor/DocumentEditorPane',
32
+ component: DocumentEditorPane,
33
+ parameters: { layout: 'fullscreen', backgrounds: { default: 'dark' } },
34
+ decorators: [
35
+ (Story) => (
36
+ <div className="h-[620px] w-[900px] bg-background text-foreground">
37
+ <Story />
38
+ </div>
39
+ ),
40
+ ],
41
+ }
42
+
43
+ export default meta
44
+ type Story = StoryObj<typeof DocumentEditorPane>
45
+
46
+ export const PreviewDark: Story = {
47
+ name: 'Preview (dark theme)',
48
+ args: {
49
+ title: 'gtm-agent-repo.md',
50
+ subtitle: 'knowledge/gtm-agent-repo.md',
51
+ eyebrow: 'Artifact',
52
+ markdown: MARKDOWN_SOURCE,
53
+ mode: 'preview',
54
+ },
55
+ }
56
+
57
+ export const PreviewLight: Story = {
58
+ name: 'Preview (light theme)',
59
+ parameters: { backgrounds: { default: 'light' } },
60
+ decorators: [
61
+ (Story) => (
62
+ <div data-theme="tangle-light" className="h-[620px] w-[900px] bg-background text-foreground">
63
+ <Story />
64
+ </div>
65
+ ),
66
+ ],
67
+ args: {
68
+ title: 'gtm-agent-repo.md',
69
+ subtitle: 'knowledge/gtm-agent-repo.md',
70
+ eyebrow: 'Artifact',
71
+ markdown: MARKDOWN_SOURCE,
72
+ mode: 'preview',
73
+ },
74
+ }
@@ -182,6 +182,8 @@ export function DocumentEditorPane({
182
182
  footer,
183
183
  className,
184
184
  contentClassName,
185
+ headerClassName,
186
+ hideTitleBlock,
185
187
  tabs,
186
188
  toolbar,
187
189
  markdown = "",
@@ -287,11 +289,14 @@ export function DocumentEditorPane({
287
289
  const preview = (
288
290
  <div
289
291
  className={cn(
290
- "rounded-[var(--radius-lg)] border border-border bg-background p-5",
292
+ // The outer margin is the card's gutter (rather than padding on the tab
293
+ // panel) so `previewClassName` can govern the whole box — gutter, border,
294
+ // surface, and inner padding — e.g. to render the document full-bleed.
295
+ "m-4 rounded-[var(--radius-lg)] border border-border bg-card p-5",
291
296
  previewClassName,
292
297
  )}
293
298
  >
294
- <Markdown className="prose-sm max-w-none">{draft}</Markdown>
299
+ <Markdown>{draft}</Markdown>
295
300
  </div>
296
301
  );
297
302
 
@@ -337,9 +342,11 @@ export function DocumentEditorPane({
337
342
  tabs={tabs}
338
343
  className={className}
339
344
  contentClassName={contentClassName}
345
+ headerClassName={headerClassName}
340
346
  toolbar={editorToolbar}
347
+ hideTitleBlock={hideTitleBlock}
341
348
  >
342
- <TabsContent value="preview" className="mt-0 h-full px-4 py-4">
349
+ <TabsContent value="preview" className="mt-0 h-full">
343
350
  {preview}
344
351
  </TabsContent>
345
352
  <TabsContent value="edit" className="mt-0 h-full px-4 py-4">
@@ -44,6 +44,8 @@ export interface FileArtifactPaneProps extends Omit<FilePreviewProps, "className
44
44
  toolbar?: ArtifactPaneProps["toolbar"];
45
45
  footer?: ArtifactPaneProps["footer"];
46
46
  className?: string;
47
+ headerClassName?: ArtifactPaneProps["headerClassName"];
48
+ hideTitleBlock?: ArtifactPaneProps["hideTitleBlock"];
47
49
  editor?: FileArtifactPaneEditorOptions;
48
50
  }
49
51
 
@@ -68,6 +70,8 @@ export function FileArtifactPane({
68
70
  toolbar,
69
71
  footer,
70
72
  className,
73
+ headerClassName,
74
+ hideTitleBlock,
71
75
  editor,
72
76
  }: FileArtifactPaneProps) {
73
77
  const showTabs = tabs.length > 0 && onTabSelect && onTabClose;
@@ -83,7 +87,9 @@ export function FileArtifactPane({
83
87
  detectFileFormat(filename, mimeType) === "markdown" ||
84
88
  (path ? detectFileFormat(path, mimeType) === "markdown" : false);
85
89
  const isEditableMarkdown = isMarkdown && editor?.enabled;
86
- const headerActions = (
90
+ // Undefined (not an empty fragment) when there are no actions, so ArtifactPane
91
+ // can collapse the header row entirely under hideTitleBlock.
92
+ const headerActions = onDownload || onClose ? (
87
93
  <>
88
94
  {onDownload && (
89
95
  <button
@@ -106,7 +112,7 @@ export function FileArtifactPane({
106
112
  </button>
107
113
  )}
108
114
  </>
109
- );
115
+ ) : undefined;
110
116
 
111
117
  if (isEditableMarkdown) {
112
118
  return (
@@ -122,6 +128,8 @@ export function FileArtifactPane({
122
128
  className={className}
123
129
  tabs={paneTabs}
124
130
  headerActions={headerActions}
131
+ headerClassName={headerClassName}
132
+ hideTitleBlock={hideTitleBlock}
125
133
  >
126
134
  <div className="flex min-h-[12rem] items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-border bg-muted text-sm text-muted-foreground">
127
135
  Loading editor…
@@ -139,6 +147,8 @@ export function FileArtifactPane({
139
147
  className={className}
140
148
  tabs={paneTabs}
141
149
  headerActions={headerActions}
150
+ headerClassName={headerClassName}
151
+ hideTitleBlock={hideTitleBlock}
142
152
  markdown={content ?? ""}
143
153
  mode={editor.mode}
144
154
  defaultMode={editor.defaultMode}
@@ -170,6 +180,8 @@ export function FileArtifactPane({
170
180
  className={className}
171
181
  tabs={paneTabs}
172
182
  headerActions={headerActions}
183
+ headerClassName={headerClassName}
184
+ hideTitleBlock={hideTitleBlock}
173
185
  >
174
186
  <FilePreview
175
187
  filename={filename}
@@ -43,7 +43,15 @@ Markdown still renders as **prose**, not as a code block.
43
43
 
44
44
  - Themed headings
45
45
  - \`inline code\`
46
- - [links](https://tangle.tools)`
46
+ - [links](https://tangle.tools)
47
+
48
+ ---
49
+
50
+ | Element | Expected |
51
+ | --- | --- |
52
+ | Body | readable foreground |
53
+ | Table | subtle borders |
54
+ | Code | tokenized accent |`
47
55
 
48
56
  const meta: Meta<typeof FilePreview> = {
49
57
  title: 'Files/FilePreview',
@@ -85,3 +93,16 @@ export const MarkdownProse: Story = {
85
93
  name: 'Markdown (rendered prose)',
86
94
  args: { filename: 'README.md', content: MARKDOWN_SOURCE },
87
95
  }
96
+
97
+ export const MarkdownProseLight: Story = {
98
+ name: 'Markdown (light theme)',
99
+ parameters: { backgrounds: { default: 'light' } },
100
+ decorators: [
101
+ (Story) => (
102
+ <div data-theme="tangle-light" className="h-[560px] w-[760px] bg-background text-foreground">
103
+ <Story />
104
+ </div>
105
+ ),
106
+ ],
107
+ args: { filename: 'README.md', content: MARKDOWN_SOURCE },
108
+ }
@@ -171,7 +171,7 @@ function PdfPreview({ blobUrl, filename }: { blobUrl: string; filename: string }
171
171
 
172
172
  function TextPreview({ content }: { content: string }) {
173
173
  return (
174
- <pre className="bg-background rounded-[var(--radius-md)] border border-border p-4 overflow-auto max-h-[70vh] text-sm text-foreground font-mono leading-[1.55]">
174
+ <pre className="bg-card rounded-[var(--radius-md)] border border-border p-4 overflow-auto max-h-[70vh] text-sm text-foreground font-mono leading-[1.55]">
175
175
  {content}
176
176
  </pre>
177
177
  );
@@ -199,7 +199,7 @@ function UnsupportedPreview({
199
199
  function MarkdownPreview({ content }: { content: string }) {
200
200
  return (
201
201
  <div className="rounded-[var(--radius-md)] border border-border bg-background p-5">
202
- <Markdown className="prose-sm max-w-none">{content}</Markdown>
202
+ <Markdown>{content}</Markdown>
203
203
  </div>
204
204
  );
205
205
  }
@@ -10,7 +10,7 @@ const markdownMeta: Meta<typeof Markdown> = {
10
10
  parameters: { layout: 'centered', backgrounds: { default: 'dark' } },
11
11
  decorators: [
12
12
  (Story) => (
13
- <div className="w-[680px] p-6 rounded-xl bg-card text-foreground">
13
+ <div className="w-[680px] rounded-xl bg-card p-6 text-foreground">
14
14
  <Story />
15
15
  </div>
16
16
  ),
@@ -20,6 +20,60 @@ const markdownMeta: Meta<typeof Markdown> = {
20
20
  export default markdownMeta
21
21
  type Story = StoryObj<typeof Markdown>
22
22
 
23
+ const CONTRAST_MARKDOWN = `# GTM Agent Repository Overview
24
+
25
+ Source: \`github.com/tangle-network/gtm-agent\` Live at: [gtm.tangle.tools](https://gtm.tangle.tools)
26
+
27
+ ---
28
+
29
+ ## What It Is
30
+
31
+ GTM Agent is an AI-powered go-to-market workspace where founders get a sandboxed environment with an AI co-founder.
32
+
33
+ ## Architecture
34
+
35
+ | Layer | Owns | Location |
36
+ |------|------|----------|
37
+ | Engine | Stream normalization, DB port, harness | \`@tangle-network/agent-app\` |
38
+ | Shell | Auth, billing, RBAC, vault | \`src/routes/**\` |
39
+ | Runtime | Customer motion | Live sandbox |
40
+
41
+ ## Tech Stack
42
+
43
+ - **Frontend:** Remix + Tailwind v4
44
+ - **Database:** SQLite + Drizzle ORM
45
+ - **Sandbox:** \`@tangle-network/sandbox\`
46
+
47
+ > Prime directive: ship the shell, never hardcode customer motion.
48
+
49
+ \`inline code\` stays readable beside paragraphs.
50
+
51
+ \`\`\`ts
52
+ export function readable(theme: 'light' | 'dark') {
53
+ return theme
54
+ }
55
+ \`\`\`
56
+ `
57
+
58
+ export const LightThemeRegression: Story = {
59
+ name: 'Light theme regression',
60
+ parameters: { backgrounds: { default: 'light' } },
61
+ render: () => (
62
+ <div data-theme="tangle-light" className="rounded-xl bg-background p-6 text-foreground">
63
+ <Markdown>{CONTRAST_MARKDOWN}</Markdown>
64
+ </div>
65
+ ),
66
+ }
67
+
68
+ export const DarkThemeRegression: Story = {
69
+ name: 'Dark theme regression',
70
+ render: () => (
71
+ <div className="rounded-xl bg-background p-6 text-foreground">
72
+ <Markdown>{CONTRAST_MARKDOWN}</Markdown>
73
+ </div>
74
+ ),
75
+ }
76
+
23
77
  export const Prose: Story = {
24
78
  name: 'Prose',
25
79
  args: {
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { Markdown } from "./markdown";
4
+
5
+ describe("Markdown", () => {
6
+ it("uses the tokenized Tangle prose class instead of host typography defaults", () => {
7
+ const { container } = render(<Markdown>{"# Heading\n\nBody with `code`."}</Markdown>);
8
+ const surface = container.firstElementChild;
9
+
10
+ expect(surface).toHaveClass("tangle-prose");
11
+ expect(surface).not.toHaveClass("prose");
12
+ expect(surface).not.toHaveClass("dark:prose-invert");
13
+ });
14
+
15
+ it("keeps inline code on semantic tokens", () => {
16
+ const { container } = render(<Markdown>{"Use `token` values."}</Markdown>);
17
+ const inlineCode = container.querySelector("code");
18
+
19
+ expect(inlineCode).toHaveClass("border-border");
20
+ expect(inlineCode).toHaveClass("bg-card");
21
+ expect(inlineCode).toHaveClass("text-[var(--code-keyword)]");
22
+ });
23
+ });
@@ -17,7 +17,7 @@ export interface MarkdownProps {
17
17
  export const Markdown = memo(({ children, className }: MarkdownProps) => {
18
18
  return (
19
19
  <div
20
- className={cn("prose prose-sm dark:prose-invert max-w-none", className)}
20
+ className={cn("tangle-prose max-w-none text-sm", className)}
21
21
  >
22
22
  <ReactMarkdown
23
23
  remarkPlugins={[remarkGfm]}
@@ -14,6 +14,15 @@ export interface ArtifactPaneProps {
14
14
  children?: ReactNode;
15
15
  className?: string;
16
16
  contentClassName?: string;
17
+ /** Extra classes for the header band (e.g. to align its surface with the host chrome). */
18
+ headerClassName?: string;
19
+ /**
20
+ * Suppress the eyebrow/title/subtitle/meta block. Use when the host already
21
+ * shows the artifact's identity elsewhere (e.g. a surrounding path row), so the
22
+ * pane keeps only its tabs, toolbar, and header actions. The whole header row
23
+ * collapses when nothing else would render in it.
24
+ */
25
+ hideTitleBlock?: boolean;
17
26
  }
18
27
 
19
28
  /**
@@ -33,8 +42,17 @@ export function ArtifactPane({
33
42
  children,
34
43
  className,
35
44
  contentClassName,
45
+ headerClassName,
46
+ hideTitleBlock = false,
36
47
  }: ArtifactPaneProps) {
37
48
  const hasContent = children !== undefined && children !== null;
49
+ // With the title block hidden, still render the top row if there are header
50
+ // actions to place; the block itself just collapses to empty.
51
+ const showTitleRow = !hideTitleBlock || Boolean(headerActions);
52
+ const hasHeader = showTitleRow || Boolean(tabs) || Boolean(toolbar);
53
+ // The toolbar's top divider only reads as a separator when something sits
54
+ // above it; as the header's first row it would double the host's own border.
55
+ const toolbarNeedsDivider = showTitleRow || Boolean(tabs);
38
56
 
39
57
  return (
40
58
  <section
@@ -43,35 +61,43 @@ export function ArtifactPane({
43
61
  className,
44
62
  )}
45
63
  >
46
- <header className="border-b border-border bg-muted/10">
47
- <div className="flex items-start justify-between gap-3 px-3 py-2">
48
- <div className="min-w-0 flex-1">
49
- {eyebrow && (
50
- <div className="mb-1 inline-flex max-w-full items-center px-1 py-0.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
51
- {eyebrow}
64
+ {hasHeader && (
65
+ <header className={cn("border-b border-border bg-muted/10", headerClassName)}>
66
+ {showTitleRow && (
67
+ <div className="flex items-start justify-between gap-3 px-3 py-2">
68
+ <div className="min-w-0 flex-1">
69
+ {!hideTitleBlock && (
70
+ <>
71
+ {eyebrow && (
72
+ <div className="mb-1 inline-flex max-w-full items-center px-1 py-0.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
73
+ {eyebrow}
74
+ </div>
75
+ )}
76
+ <div className="min-w-0 text-[13px] font-medium text-foreground">
77
+ {title}
78
+ </div>
79
+ {(subtitle || meta) && (
80
+ <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs leading-relaxed text-muted-foreground">
81
+ {subtitle && <span className="truncate">{subtitle}</span>}
82
+ {meta && <span className="flex items-center gap-2">{meta}</span>}
83
+ </div>
84
+ )}
85
+ </>
86
+ )}
52
87
  </div>
53
- )}
54
- <div className="min-w-0 text-[13px] font-medium text-foreground">
55
- {title}
88
+ {headerActions && (
89
+ <div className="flex shrink-0 items-center gap-1.5">{headerActions}</div>
90
+ )}
91
+ </div>
92
+ )}
93
+ {tabs}
94
+ {toolbar && (
95
+ <div className={cn("px-3 py-2", toolbarNeedsDivider && "border-t border-border")}>
96
+ {toolbar}
56
97
  </div>
57
- {(subtitle || meta) && (
58
- <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs leading-relaxed text-muted-foreground">
59
- {subtitle && <span className="truncate">{subtitle}</span>}
60
- {meta && <span className="flex items-center gap-2">{meta}</span>}
61
- </div>
62
- )}
63
- </div>
64
- {headerActions && (
65
- <div className="flex shrink-0 items-center gap-1.5">{headerActions}</div>
66
98
  )}
67
- </div>
68
- {tabs}
69
- {toolbar && (
70
- <div className="border-t border-border px-3 py-2">
71
- {toolbar}
72
- </div>
73
- )}
74
- </header>
99
+ </header>
100
+ )}
75
101
 
76
102
  <div className={cn("min-h-0 flex-1 overflow-auto", contentClassName)}>
77
103
  {hasContent ? (