@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.
- package/CHANGELOG.md +30 -0
- package/dist/{artifact-pane-DvJyPWV4.d.ts → artifact-pane-DpuWcAiF.d.ts} +10 -1
- package/dist/chat.d.ts +8 -2
- package/dist/chat.js +5 -5
- package/dist/{chunk-52Y3FMFI.js → chunk-3M7BPSSW.js} +1 -1
- package/dist/chunk-4ORVXSMU.js +507 -0
- package/dist/{chunk-LELGOLFV.js → chunk-GGDYWMFF.js} +15 -7
- package/dist/{chunk-CCM2KRQH.js → chunk-HW4ROISY.js} +1 -1
- package/dist/{chunk-KBOD47AW.js → chunk-J3YZIQNI.js} +2 -2
- package/dist/{chunk-CSAIKY36.js → chunk-LTEKI7YJ.js} +12 -7
- package/dist/{chunk-KHO7DYXH.js → chunk-OTZWQJ5W.js} +107 -72
- package/dist/{chunk-FJBTCTZM.js → chunk-P5F43A65.js} +1 -1
- package/dist/{chunk-SZ44QDA6.js → chunk-RADUPP44.js} +12 -5
- package/dist/{chunk-HHYUPAOJ.js → chunk-SRAKCVPO.js} +49 -81
- package/dist/{document-editor-pane-DyDEX_Zm.d.ts → document-editor-pane-C3cYEDWZ.d.ts} +2 -2
- package/dist/{document-editor-pane-WCTA3ZOE.js → document-editor-pane-OQNGM4RJ.js} +3 -3
- package/dist/editor.d.ts +3 -3
- package/dist/editor.js +3 -3
- package/dist/files.d.ts +5 -3
- package/dist/files.js +3 -3
- package/dist/hooks.js +4 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +9 -9
- package/dist/markdown.js +1 -1
- package/dist/openui.js +2 -2
- package/dist/primitives.d.ts +1 -1
- package/dist/primitives.js +1 -1
- package/dist/run.d.ts +4 -3
- package/dist/run.js +5 -5
- package/dist/sdk-hooks.js +4 -4
- package/package.json +1 -1
- package/src/chat/agent-timeline.stories.tsx +11 -0
- package/src/chat/agent-timeline.test.tsx +34 -0
- package/src/chat/agent-timeline.tsx +65 -12
- package/src/chat/chat-container.tsx +5 -0
- package/src/editor/document-editor-pane.stories.tsx +74 -0
- package/src/editor/document-editor-pane.tsx +10 -3
- package/src/files/file-artifact-pane.tsx +14 -2
- package/src/files/file-preview.stories.tsx +22 -1
- package/src/files/file-preview.tsx +2 -2
- package/src/markdown/markdown.stories.tsx +55 -1
- package/src/markdown/markdown.test.tsx +23 -0
- package/src/markdown/markdown.tsx +1 -1
- package/src/primitives/artifact-pane.tsx +52 -26
- package/src/run/expandable-content.test.tsx +52 -0
- package/src/run/expandable-content.tsx +73 -0
- package/src/run/expanded-tool-detail.tsx +17 -12
- package/src/run/inline-thinking-item.stories.tsx +30 -1
- package/src/run/inline-thinking-item.tsx +34 -70
- package/src/run/inline-tool-item.tsx +30 -116
- package/src/run/run-row-shell.test.tsx +38 -0
- package/src/run/run-row-shell.tsx +191 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
|
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]
|
|
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("
|
|
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
|
-
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<div className="
|
|
51
|
-
{
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
</
|
|
68
|
-
|
|
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 ? (
|