@workerdeck/ui 0.23.0 → 1.0.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/build/{SessionPanel-DUt2VzXG.mjs → SessionPanel-DDgBkHsi.mjs} +2151 -3357
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +15 -23
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +5 -126
- package/build/workspace.mjs +11 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +3 -9
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +315 -996
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +104 -219
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +96 -32
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-BobynUo4.d.mts +0 -669
- package/build/SessionPanel-DUt2VzXG.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -2,6 +2,8 @@ import type { ContextUsage } from '@workerdeck/protocol'
|
|
|
2
2
|
import { Dialog, DialogBody, DialogContent, DialogHeader } from '../ui/Dialog.tsx'
|
|
3
3
|
import { cn } from '../../lib/utils.ts'
|
|
4
4
|
import { formatTokens } from '../../lib/format.ts'
|
|
5
|
+
import { cssColor } from '../../lib/css.ts'
|
|
6
|
+
import { meterTintClass } from '../../lib/status.ts'
|
|
5
7
|
|
|
6
8
|
export interface ContextDialogProps {
|
|
7
9
|
usage?: ContextUsage
|
|
@@ -10,75 +12,52 @@ export interface ContextDialogProps {
|
|
|
10
12
|
className?: string
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
/** The CLI reports category colors as its own theme token names ('inactive',
|
|
14
|
-
* 'promptBorder', ...), not CSS colors — only pass through what CSS can render. */
|
|
15
|
-
const cssColor = (color: string): string | undefined =>
|
|
16
|
-
typeof CSS !== 'undefined' && CSS.supports('color', color) ? color : undefined
|
|
17
|
-
|
|
18
|
-
const usageTint = (pct: number) => (pct >= 90 ? 'bg-danger' : pct >= 70 ? 'bg-warning' : 'bg-accent')
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* What is in the model's context window right now, category by category.
|
|
22
|
-
*
|
|
23
|
-
* One of the three panels the status bar opens. Context, usage and session info
|
|
24
|
-
* are different questions asked at different moments, so they are different
|
|
25
|
-
* screens rather than one "details" list you scroll past two answers to reach.
|
|
26
|
-
*/
|
|
27
15
|
export function ContextDialog({ usage, open, onOpenChange, className }: ContextDialogProps) {
|
|
28
16
|
return (
|
|
29
17
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
30
18
|
<DialogContent className={className}>
|
|
31
|
-
<DialogHeader title=
|
|
19
|
+
<DialogHeader title="Context" description={usage?.model} />
|
|
32
20
|
<DialogBody>
|
|
33
21
|
{!usage ? (
|
|
34
|
-
<p className=
|
|
22
|
+
<p className="py-6 text-center text-body-sm text-fg-4">
|
|
35
23
|
No reading yet — the context window is measured after a turn completes.
|
|
36
24
|
</p>
|
|
37
25
|
) : (
|
|
38
26
|
<>
|
|
39
|
-
<div className=
|
|
40
|
-
<span className=
|
|
41
|
-
<span className=
|
|
42
|
-
{formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} ·{
|
|
43
|
-
{usage.percentage.toFixed(0)}%
|
|
27
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
28
|
+
<span className="text-label text-fg-3">Used</span>
|
|
29
|
+
<span className="font-mono text-body-sm text-fg-1">
|
|
30
|
+
{formatTokens(usage.totalTokens)} / {formatTokens(usage.maxTokens)} · {usage.percentage.toFixed(0)}%
|
|
44
31
|
</span>
|
|
45
32
|
</div>
|
|
46
|
-
<div className=
|
|
33
|
+
<div className="mt-2 h-2 overflow-hidden rounded-full bg-border">
|
|
47
34
|
<div
|
|
48
|
-
className={cn('h-full rounded-full',
|
|
35
|
+
className={cn('h-full rounded-full', meterTintClass(usage.percentage))}
|
|
49
36
|
style={{ width: `${Math.min(100, Math.max(2, usage.percentage))}%` }}
|
|
50
37
|
/>
|
|
51
38
|
</div>
|
|
52
|
-
{/* Codex reports occupancy with no breakdown, so an engine can send
|
|
53
|
-
a real reading and an empty `categories`. A "Breakdown" heading
|
|
54
|
-
over nothing reads as a failed load; the row above already says
|
|
55
|
-
everything that is known. */}
|
|
56
39
|
{usage.categories.length > 0 ? (
|
|
57
|
-
<div className=
|
|
58
|
-
<h3 className=
|
|
59
|
-
<div className=
|
|
40
|
+
<div className="mt-5">
|
|
41
|
+
<h3 className="text-label font-medium text-fg-3">Breakdown</h3>
|
|
42
|
+
<div className="mt-2 flex flex-col gap-3">
|
|
60
43
|
{usage.categories.map((category) => {
|
|
61
44
|
const share = (category.tokens / Math.max(usage.maxTokens, 1)) * 100
|
|
62
45
|
const color = cssColor(category.color)
|
|
63
46
|
return (
|
|
64
47
|
<div key={category.name}>
|
|
65
|
-
<div className=
|
|
66
|
-
<span className=
|
|
48
|
+
<div className="flex items-baseline justify-between gap-3">
|
|
49
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
67
50
|
<span
|
|
68
|
-
className=
|
|
51
|
+
className="size-2 shrink-0 rounded-full bg-fg-4"
|
|
69
52
|
style={color ? { backgroundColor: color } : undefined}
|
|
70
53
|
/>
|
|
71
|
-
<span className=
|
|
72
|
-
{category.name}
|
|
73
|
-
</span>
|
|
74
|
-
</span>
|
|
75
|
-
<span className='shrink-0 font-mono text-label text-fg-3'>
|
|
76
|
-
{formatTokens(category.tokens)}
|
|
54
|
+
<span className="truncate text-body-sm text-fg-1">{category.name}</span>
|
|
77
55
|
</span>
|
|
56
|
+
<span className="shrink-0 font-mono text-label text-fg-3">{formatTokens(category.tokens)}</span>
|
|
78
57
|
</div>
|
|
79
|
-
<div className=
|
|
58
|
+
<div className="mt-1 h-1 overflow-hidden rounded-full bg-border">
|
|
80
59
|
<div
|
|
81
|
-
className=
|
|
60
|
+
className="h-full rounded-full bg-fg-4"
|
|
82
61
|
style={{
|
|
83
62
|
width: `${Math.min(100, share)}%`,
|
|
84
63
|
backgroundColor: color,
|
|
@@ -4,37 +4,15 @@ import { cn } from '../../lib/utils.ts'
|
|
|
4
4
|
import { formatTokens } from '../../lib/format.ts'
|
|
5
5
|
import { meterColorClass } from '../../lib/status.ts'
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
* question the session screen cannot: *which* of these thirty sessions is
|
|
12
|
-
* bloating. That is also why it is a ring and not a number — across a list, a
|
|
13
|
-
* ring is read at a glance and `71%` has to be read one row at a time.
|
|
14
|
-
*
|
|
15
|
-
* **Absent draws nothing, and absent is not zero.** A promptless session, a
|
|
16
|
-
* parked one from before the field existed, or an engine that reports no window
|
|
17
|
-
* has no reading; an empty ring would claim its context is empty rather than
|
|
18
|
-
* unknown. The title carries the exact numbers for anyone who wants them.
|
|
19
|
-
*
|
|
20
|
-
* Shared rather than spelled per client: the dashboard's row and the VS Code
|
|
21
|
-
* sidebar's card both draw it, and two copies would be two sets of thresholds
|
|
22
|
-
* the day one of them is nudged.
|
|
23
|
-
*/
|
|
24
|
-
export function ContextRing({
|
|
25
|
-
usage,
|
|
26
|
-
size = 11,
|
|
27
|
-
className,
|
|
28
|
-
}: {
|
|
29
|
-
usage: ContextReading | undefined
|
|
30
|
-
size?: number
|
|
31
|
-
className?: string
|
|
32
|
-
}) {
|
|
33
|
-
if (usage === undefined) return null
|
|
7
|
+
export function ContextRing({ usage, size = 11, className }: { usage: ContextReading | undefined; size?: number; className?: string }) {
|
|
8
|
+
if (usage === undefined) {
|
|
9
|
+
return null
|
|
10
|
+
}
|
|
34
11
|
return (
|
|
35
12
|
<span
|
|
36
13
|
title={`Context ${usage.percentage.toFixed(0)}% · ${formatTokens(usage.totalTokens)} / ${formatTokens(usage.maxTokens)}`}
|
|
37
|
-
className={cn('shrink-0 leading-none', meterColorClass(usage.percentage), className)}
|
|
14
|
+
className={cn('shrink-0 leading-none', meterColorClass(usage.percentage), className)}
|
|
15
|
+
>
|
|
38
16
|
<ProgressRing value={usage.percentage} size={size} strokeWidth={2} />
|
|
39
17
|
</span>
|
|
40
18
|
)
|
|
@@ -8,40 +8,28 @@ export interface ConversationProps extends Omit<HTMLAttributes<HTMLDivElement>,
|
|
|
8
8
|
children: ReactNode
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/** Scroll container that stays pinned to the bottom while streaming, unless the user
|
|
12
|
-
* scrolls up — plus a floating scroll-to-bottom button.
|
|
13
|
-
*
|
|
14
|
-
* **Nothing here animates its scroll position.** `initial` and `resize` are both
|
|
15
|
-
* `'instant'` and are no longer configurable: the follow spring's job is to
|
|
16
|
-
* *stay* at the bottom, not to travel there, and every travel a reader has ever
|
|
17
|
-
* complained about on this surface was an animation we asked for. The `resize`
|
|
18
|
-
* prop used to exist so a caller could pick, which required the caller to know
|
|
19
|
-
* whether the transcript was streaming or replaying — a latch's worth of
|
|
20
|
-
* machinery to decide something that has one right answer. */
|
|
21
11
|
export function Conversation({ className, children, ...props }: ConversationProps) {
|
|
22
12
|
return (
|
|
23
13
|
<StickToBottom
|
|
24
|
-
data-slot=
|
|
14
|
+
data-slot="conversation"
|
|
25
15
|
className={cn('relative flex-1 overflow-y-auto', className)}
|
|
26
|
-
initial=
|
|
27
|
-
resize=
|
|
28
|
-
role=
|
|
29
|
-
{...props}
|
|
16
|
+
initial="instant"
|
|
17
|
+
resize="instant"
|
|
18
|
+
role="log"
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
30
21
|
{children}
|
|
31
22
|
</StickToBottom>
|
|
32
23
|
)
|
|
33
24
|
}
|
|
34
25
|
|
|
35
|
-
export function ConversationContent({
|
|
36
|
-
className,
|
|
37
|
-
children,
|
|
38
|
-
...props
|
|
39
|
-
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
|
26
|
+
export function ConversationContent({ className, children, ...props }: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
|
40
27
|
return (
|
|
41
28
|
<StickToBottom.Content
|
|
42
|
-
data-slot=
|
|
29
|
+
data-slot="conversation-content"
|
|
43
30
|
className={cn('mx-auto flex w-full max-w-[var(--wd-transcript-max-width)] flex-col gap-4 px-4 py-4', className)}
|
|
44
|
-
{...props}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
45
33
|
{children}
|
|
46
34
|
</StickToBottom.Content>
|
|
47
35
|
)
|
|
@@ -49,19 +37,19 @@ export function ConversationContent({
|
|
|
49
37
|
|
|
50
38
|
export function ConversationScrollButton({ className }: { className?: string }) {
|
|
51
39
|
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
|
|
52
|
-
if (isAtBottom)
|
|
40
|
+
if (isAtBottom) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
53
43
|
return (
|
|
54
44
|
<Button
|
|
55
|
-
variant=
|
|
56
|
-
size=
|
|
57
|
-
aria-label=
|
|
58
|
-
data-slot=
|
|
59
|
-
className={cn(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
onClick={() => void scrollToBottom('instant')}>
|
|
64
|
-
<ArrowDown className='size-4' />
|
|
45
|
+
variant="outline"
|
|
46
|
+
size="icon"
|
|
47
|
+
aria-label="Scroll to bottom"
|
|
48
|
+
data-slot="conversation-scroll-button"
|
|
49
|
+
className={cn('absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full shadow-(--shadow-md)', className)}
|
|
50
|
+
onClick={() => void scrollToBottom('instant')}
|
|
51
|
+
>
|
|
52
|
+
<ArrowDown className="size-4" />
|
|
65
53
|
</Button>
|
|
66
54
|
)
|
|
67
55
|
}
|
|
@@ -13,32 +13,15 @@ export interface EditorTabsProps {
|
|
|
13
13
|
className?: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
* The open-file tab strip.
|
|
18
|
-
*
|
|
19
|
-
* Hand-rolled rather than built on `@base-ui/react`'s `Tabs`: a VS Code tab
|
|
20
|
-
* carries a close button, and a `<button>` inside a `<button>` is invalid HTML —
|
|
21
|
-
* getting the primitive to render something else costs more than the roving
|
|
22
|
-
* tabindex it would have provided. So that part is here, explicitly, along with
|
|
23
|
-
* the two affordances that actually make a tab strip feel right: middle-click to
|
|
24
|
-
* close, and the active tab scrolling itself into view.
|
|
25
|
-
*
|
|
26
|
-
* Deliberately state-free. Which files are open, which is focused and what
|
|
27
|
-
* closing does are all decided by `useOpenFiles`.
|
|
28
|
-
*/
|
|
29
16
|
export function EditorTabs({ files, activePath, onActivate, onClose, className }: EditorTabsProps) {
|
|
30
17
|
return (
|
|
31
|
-
// Grouped, so moving along the strip shows each tab's path immediately
|
|
32
|
-
// instead of re-serving the open delay on every tab.
|
|
33
18
|
<TooltipProvider delay={500} closeDelay={0}>
|
|
34
19
|
<div
|
|
35
|
-
data-slot=
|
|
36
|
-
role=
|
|
37
|
-
aria-label=
|
|
38
|
-
className={cn(
|
|
39
|
-
|
|
40
|
-
className,
|
|
41
|
-
)}>
|
|
20
|
+
data-slot="editor-tabs"
|
|
21
|
+
role="tablist"
|
|
22
|
+
aria-label="Open files"
|
|
23
|
+
className={cn('flex shrink-0 items-stretch overflow-x-auto border-b border-border bg-surface', className)}
|
|
24
|
+
>
|
|
42
25
|
{files.map((file) => (
|
|
43
26
|
<Tab
|
|
44
27
|
key={file.path}
|
|
@@ -49,7 +32,9 @@ export function EditorTabs({ files, activePath, onActivate, onClose, className }
|
|
|
49
32
|
onArrow={(direction) => {
|
|
50
33
|
const index = files.findIndex((f) => f.path === file.path)
|
|
51
34
|
const next = files[index + direction]
|
|
52
|
-
if (next)
|
|
35
|
+
if (next) {
|
|
36
|
+
onActivate(next.path)
|
|
37
|
+
}
|
|
53
38
|
}}
|
|
54
39
|
/>
|
|
55
40
|
))}
|
|
@@ -73,41 +58,41 @@ function Tab({
|
|
|
73
58
|
}) {
|
|
74
59
|
const dirty = isDirty(file)
|
|
75
60
|
const ref = useRef<HTMLDivElement>(null)
|
|
76
|
-
// Opening a file from the tree can push the new tab off the end of the strip;
|
|
77
|
-
// the point of opening it was to look at it. `nearest` scrolls the minimum, so
|
|
78
|
-
// a tab already on screen stays exactly where it is.
|
|
79
61
|
useEffect(() => {
|
|
80
|
-
if (active)
|
|
62
|
+
if (active) {
|
|
63
|
+
ref.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
|
64
|
+
}
|
|
81
65
|
}, [active])
|
|
82
66
|
|
|
83
67
|
const onAuxClick = (event: ReactMouseEvent) => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
68
|
+
if (event.button !== 1) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
87
71
|
event.preventDefault()
|
|
88
72
|
onClose()
|
|
89
73
|
}
|
|
90
74
|
|
|
91
75
|
return (
|
|
92
|
-
// The tab *is* the trigger (`render`), and it carries the full path — which
|
|
93
|
-
// is why the viewer no longer spends a whole row on a line of monospace
|
|
94
|
-
// nobody reads. No `title` alongside it: the browser's native tooltip would
|
|
95
|
-
// show up underneath this one.
|
|
96
76
|
<Tip
|
|
97
|
-
side=
|
|
77
|
+
side="bottom"
|
|
98
78
|
render={
|
|
99
79
|
<div
|
|
100
80
|
ref={ref}
|
|
101
|
-
role=
|
|
81
|
+
role="tab"
|
|
102
82
|
tabIndex={active ? 0 : -1}
|
|
103
83
|
aria-selected={active}
|
|
104
84
|
onClick={onActivate}
|
|
105
85
|
onAuxClick={onAuxClick}
|
|
106
86
|
onKeyDown={(event) => {
|
|
107
|
-
if (event.key === 'ArrowRight')
|
|
108
|
-
|
|
109
|
-
else if (event.key === '
|
|
110
|
-
|
|
87
|
+
if (event.key === 'ArrowRight') {
|
|
88
|
+
onArrow(1)
|
|
89
|
+
} else if (event.key === 'ArrowLeft') {
|
|
90
|
+
onArrow(-1)
|
|
91
|
+
} else if (event.key === 'Enter' || event.key === ' ') {
|
|
92
|
+
onActivate()
|
|
93
|
+
} else {
|
|
94
|
+
return
|
|
95
|
+
}
|
|
111
96
|
event.preventDefault()
|
|
112
97
|
}}
|
|
113
98
|
className={cn(
|
|
@@ -118,45 +103,32 @@ function Tab({
|
|
|
118
103
|
/>
|
|
119
104
|
}
|
|
120
105
|
content={
|
|
121
|
-
<span className=
|
|
122
|
-
<span className=
|
|
123
|
-
{file.bytes !== undefined ? (
|
|
124
|
-
<span className='text-fg-4'>{formatBytes(file.bytes)}</span>
|
|
125
|
-
) : null}
|
|
106
|
+
<span className="flex flex-col gap-0.5">
|
|
107
|
+
<span className="font-mono break-all">{file.path}</span>
|
|
108
|
+
{file.bytes !== undefined ? <span className="text-fg-4">{formatBytes(file.bytes)}</span> : null}
|
|
126
109
|
</span>
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
{/* Errors are the one state the strip shows, because a failed tab
|
|
132
|
-
otherwise looks identical to a loaded one until you focus it. */}
|
|
133
|
-
{file.status === 'error' ? <span className='shrink-0 text-danger'>!</span> : null}
|
|
110
|
+
}
|
|
111
|
+
>
|
|
112
|
+
<span className={cn('max-w-40 truncate font-mono text-label', dirty && 'italic')}>{file.name}</span>
|
|
113
|
+
{file.status === 'error' ? <span className="shrink-0 text-danger">!</span> : null}
|
|
134
114
|
<button
|
|
135
|
-
type=
|
|
115
|
+
type="button"
|
|
136
116
|
aria-label={dirty ? `Close ${file.name} (unsaved changes)` : `Close ${file.name}`}
|
|
137
117
|
onClick={(event) => {
|
|
138
|
-
// Without this the click also activates the tab being closed, which
|
|
139
|
-
// fights the reducer's focus-the-neighbour rule.
|
|
140
118
|
event.stopPropagation()
|
|
141
119
|
onClose()
|
|
142
120
|
}}
|
|
143
121
|
className={cn(
|
|
144
122
|
'shrink-0 rounded p-0.5 text-fg-4 transition-opacity hover:bg-surface-hover hover:text-fg-1',
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
? 'opacity-70'
|
|
149
|
-
: 'opacity-0 group-hover:opacity-70 focus-visible:opacity-100',
|
|
150
|
-
)}>
|
|
151
|
-
{/* VS Code's move: a dirty tab shows a dot where the ✕ goes, and the ✕
|
|
152
|
-
comes back when you point at it — so unsaved work is visible at rest
|
|
153
|
-
without taking away the way to close it. */}
|
|
123
|
+
active || dirty ? 'opacity-70' : 'opacity-0 group-hover:opacity-70 focus-visible:opacity-100',
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
154
126
|
<span className={cn('block', dirty && 'group-hover:hidden')}>
|
|
155
|
-
{dirty ? <span className=
|
|
127
|
+
{dirty ? <span className="block size-3 rounded-full bg-fg-2" /> : <X className="size-3" />}
|
|
156
128
|
</span>
|
|
157
129
|
{dirty ? (
|
|
158
|
-
<span className=
|
|
159
|
-
<X className=
|
|
130
|
+
<span className="hidden group-hover:block">
|
|
131
|
+
<X className="size-3" />
|
|
160
132
|
</span>
|
|
161
133
|
) : null}
|
|
162
134
|
</button>
|
|
@@ -1,22 +1,6 @@
|
|
|
1
1
|
import { cn } from '../../lib/utils.ts'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
* The engine's own mark, monochrome.
|
|
5
|
-
*
|
|
6
|
-
* Inlined rather than depended on: the React package these come from
|
|
7
|
-
* (`@lobehub/icons`) pulls antd, `@lobehub/ui` and a second copy of
|
|
8
|
-
* lucide-react — an absurd amount of bundle for a few glyphs in a sidebar.
|
|
9
|
-
* These are paths from `@lobehub/icons-static-svg` (MIT, © LobeHub), already
|
|
10
|
-
* `fill="currentColor"` single-path marks, so they take the row's colour like
|
|
11
|
-
* any other glyph and need no light/dark variants.
|
|
12
|
-
*
|
|
13
|
-
* The marks are trademarks of their owners (Anthropic, OpenAI, Google,
|
|
14
|
-
* DeepSeek, Moonshot), used here only to identify which engine a session runs
|
|
15
|
-
* on.
|
|
16
|
-
*
|
|
17
|
-
* Codex is drawn as OpenAI: the session's engine *is* OpenAI's, and OpenAI's
|
|
18
|
-
* mark is the one a person recognises at 12px.
|
|
19
|
-
*/
|
|
3
|
+
// Paths from `@lobehub/icons-static-svg` (MIT, © LobeHub), inlined. The marks are trademarks of their owners, used only to identify a session's engine.
|
|
20
4
|
const PATHS: Record<string, { title: string; d: string }> = {
|
|
21
5
|
claude: {
|
|
22
6
|
title: 'Claude',
|
|
@@ -40,44 +24,35 @@ const PATHS: Record<string, { title: string; d: string }> = {
|
|
|
40
24
|
},
|
|
41
25
|
}
|
|
42
26
|
|
|
43
|
-
/**
|
|
44
|
-
* Which mark a session wears.
|
|
45
|
-
*
|
|
46
|
-
* The two first-party engines are named directly. A `provider` session is
|
|
47
|
-
* whatever the host wired up, so its *model* is the only thing that says whose
|
|
48
|
-
* it is — sniffed loosely on purpose (`gemini-2.5-pro`, `deepseek-chat`,
|
|
49
|
-
* `kimi-k2`), and falling through to no mark rather than guessing wrong.
|
|
50
|
-
*/
|
|
51
27
|
export function engineMark(engine: string, model?: string): string | undefined {
|
|
52
|
-
if (engine === 'claude')
|
|
53
|
-
|
|
28
|
+
if (engine === 'claude') {
|
|
29
|
+
return 'claude'
|
|
30
|
+
}
|
|
31
|
+
if (engine === 'codex') {
|
|
32
|
+
return 'codex'
|
|
33
|
+
}
|
|
54
34
|
const id = model?.toLowerCase() ?? ''
|
|
55
|
-
if (!id)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (id.includes('
|
|
59
|
-
|
|
35
|
+
if (!id) {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
if (id.includes('gemini')) {
|
|
39
|
+
return 'gemini'
|
|
40
|
+
}
|
|
41
|
+
if (id.includes('deepseek')) {
|
|
42
|
+
return 'deepseek'
|
|
43
|
+
}
|
|
44
|
+
if (id.includes('moonshot') || id.includes('kimi')) {
|
|
45
|
+
return 'moonshot'
|
|
46
|
+
}
|
|
47
|
+
if (id.includes('claude')) {
|
|
48
|
+
return 'claude'
|
|
49
|
+
}
|
|
60
50
|
if (id.startsWith('gpt') || id.startsWith('o1') || id.startsWith('o3') || id.includes('openai')) {
|
|
61
51
|
return 'codex'
|
|
62
52
|
}
|
|
63
53
|
return undefined
|
|
64
54
|
}
|
|
65
55
|
|
|
66
|
-
/**
|
|
67
|
-
* Which engines wear their vendor's colour, and **how far it reaches**.
|
|
68
|
-
*
|
|
69
|
-
* `MARK` is the glyph, `TEXT` is the model name beside it. They are the same for
|
|
70
|
-
* Anthropic and deliberately not for OpenAI, because the two brands differ in
|
|
71
|
-
* kind rather than in hue: coral is an accent, and OpenAI's guidelines forbid
|
|
72
|
-
* adding colour to the mark at all, so theirs is pure white/black. At full
|
|
73
|
-
* contrast that is right on a 12px glyph and wrong on an 11px label — a
|
|
74
|
-
* pure-white model name is *brighter than the session title above it*, which
|
|
75
|
-
* inverts the row's whole hierarchy to say something the mark has already said.
|
|
76
|
-
* So OpenAI's name keeps the muted line it always had, which is also the most
|
|
77
|
-
* literal reading of "don't add any colors".
|
|
78
|
-
*
|
|
79
|
-
* Anything absent falls through to muted on both counts.
|
|
80
|
-
*/
|
|
81
56
|
const VENDOR_MARK: Record<string, string> = {
|
|
82
57
|
claude: 'text-vendor-claude',
|
|
83
58
|
codex: 'text-vendor-openai',
|
|
@@ -86,35 +61,16 @@ const VENDOR_TEXT: Record<string, string> = {
|
|
|
86
61
|
claude: 'text-vendor-claude',
|
|
87
62
|
}
|
|
88
63
|
|
|
89
|
-
/** The colour a session's engine mark wears. Pass it to `EngineIcon`'s
|
|
90
|
-
* `className` rather than to a parent: the svg carries its own `text-fg-3`,
|
|
91
|
-
* and only a class on the element itself is merged over it. */
|
|
92
64
|
export function vendorMarkClass(engine: string, model?: string): string {
|
|
93
65
|
return VENDOR_MARK[engineMark(engine, model) ?? ''] ?? 'text-fg-3'
|
|
94
66
|
}
|
|
95
67
|
|
|
96
|
-
/** The colour the model *name* wears beside that mark — muted unless the vendor
|
|
97
|
-
* is one whose accent survives at label size. See `VENDOR_TEXT`.
|
|
98
|
-
*
|
|
99
|
-
* The fallback is `text-fg-3` rather than the metadata line's own `text-fg-4`,
|
|
100
|
-
* so an unbranded model still sits one step above the project and gateway
|
|
101
|
-
* beside it — the order the spec lists them in, held without a colour. */
|
|
102
68
|
export function vendorTextClass(engine: string, model?: string): string {
|
|
103
69
|
return VENDOR_TEXT[engineMark(engine, model) ?? ''] ?? 'text-fg-3'
|
|
104
70
|
}
|
|
105
71
|
|
|
106
|
-
export function EngineIcon({
|
|
107
|
-
engine,
|
|
108
|
-
model,
|
|
109
|
-
className,
|
|
110
|
-
}: {
|
|
111
|
-
engine: string
|
|
112
|
-
model?: string
|
|
113
|
-
className?: string
|
|
114
|
-
}) {
|
|
72
|
+
export function EngineIcon({ engine, model, className }: { engine: string; model?: string; className?: string }) {
|
|
115
73
|
const mark = PATHS[engineMark(engine, model) ?? '']
|
|
116
|
-
// An engine this build has never heard of still gets a slot, so the second
|
|
117
|
-
// line doesn't reflow around a missing glyph.
|
|
118
74
|
if (!mark) {
|
|
119
75
|
return (
|
|
120
76
|
<span aria-hidden title={engine} className={cn('shrink-0 text-fg-3', className)}>
|
|
@@ -124,12 +80,13 @@ export function EngineIcon({
|
|
|
124
80
|
}
|
|
125
81
|
return (
|
|
126
82
|
<svg
|
|
127
|
-
viewBox=
|
|
128
|
-
fill=
|
|
129
|
-
fillRule=
|
|
130
|
-
role=
|
|
83
|
+
viewBox="0 0 24 24"
|
|
84
|
+
fill="currentColor"
|
|
85
|
+
fillRule="evenodd"
|
|
86
|
+
role="img"
|
|
131
87
|
aria-label={mark.title}
|
|
132
|
-
className={cn('size-3 shrink-0 text-fg-3', className)}
|
|
88
|
+
className={cn('size-3 shrink-0 text-fg-3', className)}
|
|
89
|
+
>
|
|
133
90
|
<title>{mark.title}</title>
|
|
134
91
|
<path d={mark.d} />
|
|
135
92
|
</svg>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { File, Folder, Link2 } from 'lucide-react'
|
|
2
|
+
import type { HostDirEntry } from '@workerdeck/protocol'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The icon for one host directory entry. A symlink is drawn as a link and never silently
|
|
6
|
+
* resolved — the caller decides what a symlink means, the tree only says it is one.
|
|
7
|
+
*/
|
|
8
|
+
export function EntryIcon({ type }: { type: HostDirEntry['type'] }) {
|
|
9
|
+
if (type === 'dir') {
|
|
10
|
+
return <Folder className="size-3.5 shrink-0 text-accent" />
|
|
11
|
+
}
|
|
12
|
+
if (type === 'symlink') {
|
|
13
|
+
return <Link2 className="size-3.5 shrink-0 text-fg-4" />
|
|
14
|
+
}
|
|
15
|
+
return <File className="size-3.5 shrink-0 text-fg-4" />
|
|
16
|
+
}
|
|
@@ -7,28 +7,21 @@ export type FileDeliveredItem = Extract<TranscriptItem, { kind: 'file_delivered'
|
|
|
7
7
|
|
|
8
8
|
export interface FileCardProps {
|
|
9
9
|
item: FileDeliveredItem
|
|
10
|
-
/** Download URL for the delivered path (the server's session file route).
|
|
11
|
-
* Without it the card renders informational only. */
|
|
12
10
|
href?: string
|
|
13
11
|
className?: string
|
|
14
12
|
}
|
|
15
13
|
|
|
16
|
-
/** A file the agent handed over (`file_delivered`): name, size, download link.
|
|
17
|
-
* The file lives in the session's in-memory VFS — the link works while the
|
|
18
|
-
* session lives. */
|
|
19
14
|
export function FileCard({ item, href, className }: FileCardProps) {
|
|
20
15
|
const name = item.path.split('/').pop() || item.path
|
|
21
16
|
return (
|
|
22
17
|
<div
|
|
23
|
-
data-slot=
|
|
24
|
-
className={cn(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
<div className='truncate font-mono text-body-sm font-medium text-fg-1'>{name}</div>
|
|
31
|
-
<div className='truncate text-label text-fg-4'>
|
|
18
|
+
data-slot="file-delivered"
|
|
19
|
+
className={cn('flex w-full items-center gap-3 rounded-lg border border-border bg-surface px-3 py-2.5', className)}
|
|
20
|
+
>
|
|
21
|
+
<FileText className="size-4 shrink-0 text-fg-3" />
|
|
22
|
+
<div className="min-w-0 flex-1">
|
|
23
|
+
<div className="truncate font-mono text-body-sm font-medium text-fg-1">{name}</div>
|
|
24
|
+
<div className="truncate text-label text-fg-4">
|
|
32
25
|
{formatBytes(item.bytes)}
|
|
33
26
|
{item.description ? ` · ${item.description}` : ''}
|
|
34
27
|
</div>
|
|
@@ -40,8 +33,9 @@ export function FileCard({ item, href, className }: FileCardProps) {
|
|
|
40
33
|
className={cn(
|
|
41
34
|
'flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5',
|
|
42
35
|
'text-body-sm text-fg-2 transition-colors hover:bg-surface-hover',
|
|
43
|
-
)}
|
|
44
|
-
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<Download className="size-3.5" />
|
|
45
39
|
Download
|
|
46
40
|
</a>
|
|
47
41
|
) : null}
|