@workerdeck/ui 0.15.0 → 0.17.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/README.md +60 -0
- package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-CnNYEX80.d.mts} +170 -21
- package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DMPhsNlW.mjs} +4759 -1483
- package/build/SessionPanel-DMPhsNlW.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +493 -45
- package/build/index.mjs +343 -17
- package/build/index.mjs.map +1 -1
- package/build/status-Ydzi7n6j.mjs +143 -0
- package/build/status-Ydzi7n6j.mjs.map +1 -0
- package/build/workspace.d.mts +9 -1
- package/build/workspace.mjs +108 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +16 -7
- package/src/components/agent/Composer.tsx +189 -89
- package/src/components/agent/Conversation.tsx +12 -12
- package/src/components/agent/FileCard.tsx +0 -26
- package/src/components/agent/FileTree.tsx +9 -8
- package/src/components/agent/Loader.tsx +22 -72
- package/src/components/agent/Message.tsx +11 -46
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/ProjectIcon.tsx +119 -0
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionBrowser.tsx +84 -3
- package/src/components/agent/SessionPanel.tsx +249 -28
- package/src/components/agent/SessionWorkspace.tsx +29 -0
- package/src/components/agent/StatusBar.tsx +20 -4
- package/src/components/agent/ToolCallCard.tsx +85 -112
- package/src/components/agent/Transcript.tsx +780 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/tool-result-fetch.tsx +36 -0
- package/src/components/agent/tool-result-image.tsx +209 -0
- package/src/components/agent/transcript-rows.ts +173 -0
- package/src/components/agent/transcript-variant.tsx +29 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +225 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/blocks.ts +232 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +770 -0
- package/src/components/terminal/image-box.ts +53 -0
- package/src/components/terminal/items.tsx +486 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +86 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +784 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +224 -0
- package/src/index.ts +36 -1
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1081 -0
- package/src/styles/theme.css +41 -0
- package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- package/src/components/agent/line-prompt.tsx +0 -249
|
@@ -1,131 +1,6 @@
|
|
|
1
1
|
import { memo } from 'react'
|
|
2
2
|
import { Streamdown } from 'streamdown'
|
|
3
3
|
import { cn } from '../../lib/utils.ts'
|
|
4
|
-
import { useLines } from './transcript-variant.tsx'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Markdown on a terminal's grid.
|
|
8
|
-
*
|
|
9
|
-
* Prose defaults give every block its own rhythm — a heading is bigger, a list
|
|
10
|
-
* item is looser, an inline `code` chip is padded taller than the line it sits
|
|
11
|
-
* in — and the result is text that visibly breathes at different rates as you
|
|
12
|
-
* scroll. A terminal has exactly one line height and one type size, and that
|
|
13
|
-
* uniformity is most of what makes it readable at a glance.
|
|
14
|
-
*
|
|
15
|
-
* So: every line 1.25rem, every block flush, and one blank line (1.25rem, i.e.
|
|
16
|
-
* exactly one row) between blocks. Headings keep their weight but lose their
|
|
17
|
-
* size. Inline code keeps a background — it still has to be distinguishable —
|
|
18
|
-
* but no vertical padding, because that is what pushes a line off the grid.
|
|
19
|
-
*/
|
|
20
|
-
const TERMINAL_PROSE = [
|
|
21
|
-
'text-body-sm leading-5',
|
|
22
|
-
'[&_*]:leading-5',
|
|
23
|
-
// Block rhythm: nothing has its own margins, and siblings are one row apart.
|
|
24
|
-
// `!` throughout because these fight the renderer's own utilities, and some of
|
|
25
|
-
// those (`space-y-*`, which is a `> * + *` rule) outrank a plain descendant
|
|
26
|
-
// selector — a list would otherwise keep its half-row gaps while every other
|
|
27
|
-
// block snapped to the grid.
|
|
28
|
-
'[&_p]:my-0! [&_pre]:my-0! [&_blockquote]:my-0! [&_table]:my-0!',
|
|
29
|
-
'[&_h1]:my-0! [&_h2]:my-0! [&_h3]:my-0! [&_h4]:my-0! [&_h5]:my-0! [&_h6]:my-0!',
|
|
30
|
-
'[&>*+*]:mt-5',
|
|
31
|
-
// Lists are rows, not paragraphs: no margin anywhere in them, and no
|
|
32
|
-
// inter-item spacing from a `space-y` on the list itself.
|
|
33
|
-
'[&_ul]:my-0! [&_ol]:my-0! [&_li]:my-0! [&_li>p]:my-0!',
|
|
34
|
-
'[&_li+li]:mt-0! [&_ul_ul]:mt-0! [&_ul_ol]:mt-0! [&_ol_ul]:mt-0! [&_ol_ol]:mt-0!',
|
|
35
|
-
'[&_li]:py-0! [&_ul]:py-0! [&_ol]:py-0!',
|
|
36
|
-
// Markers on their own column, at the width the marker actually is — the
|
|
37
|
-
// indent a terminal (and the markdown source) uses, not a typographic one.
|
|
38
|
-
// `ch` is the unit because this variant runs in a monospace face: `• ` is two
|
|
39
|
-
// cells, `1. ` is three, so the text starts exactly where it would in the raw
|
|
40
|
-
// document and a wrapped line hangs under it instead of under the bullet.
|
|
41
|
-
// Nesting then costs one marker width per level, which is the 2-space step.
|
|
42
|
-
// `list-outside` is the load-bearing half: the renderer marks lists `inside`,
|
|
43
|
-
// where the marker joins the text flow — so the indent moves the bullet too
|
|
44
|
-
// (the list sits a marker-width right of the paragraph above it) and a wrapped
|
|
45
|
-
// line runs back under the bullet instead of hanging under its own text.
|
|
46
|
-
'[&_ul]:list-outside! [&_ol]:list-outside!',
|
|
47
|
-
'[&_ul]:pl-[2ch]! [&_ol]:pl-[3ch]! [&_li]:pl-0!',
|
|
48
|
-
// Headings are weight, not size — a terminal has one type size.
|
|
49
|
-
'[&_h1]:text-body-sm [&_h2]:text-body-sm [&_h3]:text-body-sm',
|
|
50
|
-
'[&_h4]:text-body-sm [&_h5]:text-body-sm [&_h6]:text-body-sm',
|
|
51
|
-
'[&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold',
|
|
52
|
-
'[&_h4]:font-semibold [&_h5]:font-semibold [&_h6]:font-semibold',
|
|
53
|
-
'[&_blockquote]:border-l [&_blockquote]:border-border [&_blockquote]:pl-2',
|
|
54
|
-
// Inline code only (`:not(pre) > code`) — fenced blocks keep their own box.
|
|
55
|
-
// Horizontal padding is free; vertical padding is what pushes a line off grid.
|
|
56
|
-
'[&_:not(pre)>code]:rounded-none [&_:not(pre)>code]:px-1! [&_:not(pre)>code]:py-0!',
|
|
57
|
-
'[&_:not(pre)>code]:text-body-sm [&_:not(pre)>code]:leading-5! [&_:not(pre)>code]:text-fg-1',
|
|
58
|
-
'[&_th]:py-0! [&_td]:py-0!',
|
|
59
|
-
// The renderer's fenced-code card: rounded frame, its own header strip, a
|
|
60
|
-
// second rounded frame inside it, and a floating pill for the buttons. Four
|
|
61
|
-
// boxes around what is, in a terminal, a band of dim text. Flattened to
|
|
62
|
-
// exactly that — the band keeps a background so a block still reads as code,
|
|
63
|
-
// and the actions keep their top-right corner but lose the pill.
|
|
64
|
-
'[&_[data-streamdown=code-block]]:my-0! [&_[data-streamdown=code-block]]:gap-0!',
|
|
65
|
-
'[&_[data-streamdown=code-block]]:rounded-none! [&_[data-streamdown=code-block]]:border-0!',
|
|
66
|
-
'[&_[data-streamdown=code-block]]:bg-transparent! [&_[data-streamdown=code-block]]:p-0!',
|
|
67
|
-
// The language strip costs a whole row to say what the code already shows, and
|
|
68
|
-
// the actions cost another. Both go: the block is the band, and the buttons
|
|
69
|
-
// float over its top-right corner, appearing on hover or keyboard focus.
|
|
70
|
-
'[&_[data-streamdown=code-block]]:relative!',
|
|
71
|
-
'[&_[data-streamdown=code-block-header]]:hidden!',
|
|
72
|
-
'[&_div:has(>[data-streamdown=code-block-actions])]:absolute! [&_div:has(>[data-streamdown=code-block-actions])]:top-0!',
|
|
73
|
-
'[&_div:has(>[data-streamdown=code-block-actions])]:right-0! [&_div:has(>[data-streamdown=code-block-actions])]:mt-0!',
|
|
74
|
-
'[&_div:has(>[data-streamdown=code-block-actions])]:h-5! [&_div:has(>[data-streamdown=code-block-actions])]:opacity-0!',
|
|
75
|
-
'[&_div:has(>[data-streamdown=code-block-actions])]:transition-opacity',
|
|
76
|
-
'[&_[data-streamdown=code-block]:hover_div:has(>[data-streamdown=code-block-actions])]:opacity-100!',
|
|
77
|
-
'[&_[data-streamdown=code-block]:focus-within_div:has(>[data-streamdown=code-block-actions])]:opacity-100!',
|
|
78
|
-
'[&_[data-streamdown=code-block-actions]]:rounded-none! [&_[data-streamdown=code-block-actions]]:border-0!',
|
|
79
|
-
'[&_[data-streamdown=code-block-actions]]:bg-code-bg! [&_[data-streamdown=code-block-actions]]:px-1!',
|
|
80
|
-
'[&_[data-streamdown=code-block-actions]]:py-0! [&_[data-streamdown=code-block-actions]]:gap-1!',
|
|
81
|
-
'[&_[data-streamdown=code-block-actions]]:backdrop-blur-none!',
|
|
82
|
-
'[&_[data-streamdown=code-block-body]]:rounded-none! [&_[data-streamdown=code-block-body]]:border-0!',
|
|
83
|
-
'[&_[data-streamdown=code-block-body]]:bg-code-bg! [&_[data-streamdown=code-block-body]]:px-2!',
|
|
84
|
-
'[&_[data-streamdown=code-block-body]]:py-0! [&_[data-streamdown=code-block-body]]:text-body-sm!',
|
|
85
|
-
// The action buttons' hit areas, on the grid like everything else.
|
|
86
|
-
'[&_[data-streamdown=code-block-copy-button]]:p-0! [&_[data-streamdown=code-block-download-button]]:p-0!',
|
|
87
|
-
// Tables come in a frame of their own, plus a controls row above them and a
|
|
88
|
-
// second frame around the scroller — three boxes and a blank line to hold
|
|
89
|
-
// three buttons. Same treatment: the table is the content, the buttons float
|
|
90
|
-
// over its top-right on hover. The controls row is the child that has no
|
|
91
|
-
// `<table>` in it, which is also how it knows to disappear when the renderer
|
|
92
|
-
// draws no controls at all.
|
|
93
|
-
'[&_[data-streamdown=table-wrapper]]:rounded-none! [&_[data-streamdown=table-wrapper]]:border-0!',
|
|
94
|
-
'[&_[data-streamdown=table-wrapper]]:my-0! [&_[data-streamdown=table-wrapper]]:bg-transparent!',
|
|
95
|
-
'[&_[data-streamdown=table-wrapper]]:relative! [&_[data-streamdown=table-wrapper]]:gap-0!',
|
|
96
|
-
'[&_[data-streamdown=table-wrapper]]:p-0!',
|
|
97
|
-
'[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:absolute! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:top-0!',
|
|
98
|
-
'[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:right-0! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:z-10!',
|
|
99
|
-
'[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:opacity-0! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:bg-code-bg!',
|
|
100
|
-
'[&_[data-streamdown=table-wrapper]>div:not(:has(table))]:px-1! [&_[data-streamdown=table-wrapper]>div:not(:has(table))]:transition-opacity',
|
|
101
|
-
'[&_[data-streamdown=table-wrapper]:hover>div:not(:has(table))]:opacity-100!',
|
|
102
|
-
'[&_[data-streamdown=table-wrapper]:focus-within>div:not(:has(table))]:opacity-100!',
|
|
103
|
-
'[&_[data-streamdown=table-wrapper]>div:has(table)]:rounded-none! [&_[data-streamdown=table-wrapper]>div:has(table)]:border-0!',
|
|
104
|
-
'[&_[data-streamdown=table-wrapper]>div:has(table)]:bg-transparent!',
|
|
105
|
-
].join(' ')
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The controls' icons as characters.
|
|
109
|
-
*
|
|
110
|
-
* The renderer draws its copy/download affordances as line-art SVGs, which next
|
|
111
|
-
* to monospace text read as buttons from another application. These are the
|
|
112
|
-
* glyphs a terminal would use — and they inherit the surrounding font, so they
|
|
113
|
-
* sit on the same grid as the code they belong to.
|
|
114
|
-
*/
|
|
115
|
-
const glyphIcon = (glyph: string) =>
|
|
116
|
-
function GlyphIcon({ className }: { className?: string }) {
|
|
117
|
-
return (
|
|
118
|
-
<span aria-hidden className={cn('font-mono text-body-sm leading-5', className)}>
|
|
119
|
-
{glyph}
|
|
120
|
-
</span>
|
|
121
|
-
)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const TERMINAL_ICONS = {
|
|
125
|
-
DownloadIcon: glyphIcon('⤓'),
|
|
126
|
-
CopyIcon: glyphIcon('⧉'),
|
|
127
|
-
CheckIcon: glyphIcon('✓'),
|
|
128
|
-
}
|
|
129
4
|
|
|
130
5
|
export interface ResponseProps {
|
|
131
6
|
children: string
|
|
@@ -138,18 +13,12 @@ export interface ResponseProps {
|
|
|
138
13
|
* highlighted with shiki (dual theme follows [data-theme] through the dark: variant). */
|
|
139
14
|
export const Response = memo(
|
|
140
15
|
function Response({ children, streaming, className }: ResponseProps) {
|
|
141
|
-
const lines = useLines()
|
|
142
16
|
return (
|
|
143
17
|
<Streamdown
|
|
144
18
|
mode={streaming ? 'streaming' : 'static'}
|
|
145
19
|
parseIncompleteMarkdown={streaming}
|
|
146
20
|
shikiTheme={['github-light', 'github-dark']}
|
|
147
|
-
|
|
148
|
-
className={cn(
|
|
149
|
-
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
|
150
|
-
lines && TERMINAL_PROSE,
|
|
151
|
-
className,
|
|
152
|
-
)}>
|
|
21
|
+
className={cn('size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0', className)}>
|
|
153
22
|
{children}
|
|
154
23
|
</Streamdown>
|
|
155
24
|
)
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
filterRows,
|
|
21
21
|
groupRows,
|
|
22
22
|
hasFacetFilter,
|
|
23
|
+
projectLabel,
|
|
24
|
+
projectsOf,
|
|
23
25
|
sessionLabel,
|
|
24
26
|
subsetSummary,
|
|
25
27
|
} from '@workerdeck/protocol'
|
|
@@ -36,6 +38,7 @@ import { Empty } from '../ui/Empty.tsx'
|
|
|
36
38
|
import { Input } from '../ui/Input.tsx'
|
|
37
39
|
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
|
|
38
40
|
import { Spinner } from '../ui/Spinner.tsx'
|
|
41
|
+
import { ProjectIcon } from './ProjectIcon.tsx'
|
|
39
42
|
import { cn } from '../../lib/utils.ts'
|
|
40
43
|
import { formatCost, formatRelativeTime, friendlyModel } from '../../lib/format.ts'
|
|
41
44
|
|
|
@@ -88,6 +91,15 @@ export interface SessionBrowserProps {
|
|
|
88
91
|
* filtered by a control you can't currently see still says so.
|
|
89
92
|
*/
|
|
90
93
|
showControls?: boolean
|
|
94
|
+
/**
|
|
95
|
+
* Resolved project-icon bytes by content hash — `useProjectIcons`' output.
|
|
96
|
+
*
|
|
97
|
+
* Passed in rather than fetched here for the reason `ProjectIcon` states: the
|
|
98
|
+
* wire carries an *address*, and who can fetch it differs per client. Absent,
|
|
99
|
+
* or a hash not in it yet, simply draws no picture; the project's name is
|
|
100
|
+
* already there.
|
|
101
|
+
*/
|
|
102
|
+
projectIcons?: Record<string, string>
|
|
91
103
|
className?: string
|
|
92
104
|
}
|
|
93
105
|
|
|
@@ -125,6 +137,7 @@ export function SessionBrowser({
|
|
|
125
137
|
onRename,
|
|
126
138
|
emptyState,
|
|
127
139
|
showControls = true,
|
|
140
|
+
projectIcons,
|
|
128
141
|
className,
|
|
129
142
|
}: SessionBrowserProps) {
|
|
130
143
|
const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
|
|
@@ -133,6 +146,7 @@ export function SessionBrowser({
|
|
|
133
146
|
// Derived, not enumerated: a new engine or a new gateway needs no change here,
|
|
134
147
|
// and a facet with one possible value is not a choice worth showing.
|
|
135
148
|
const adapters = useMemo(() => adaptersOf(rows), [rows])
|
|
149
|
+
const projects = useMemo(() => projectsOf(rows), [rows])
|
|
136
150
|
const gateways = useMemo(() => {
|
|
137
151
|
const seen = new Map<string, string>()
|
|
138
152
|
for (const row of rows) seen.set(row.hostId, row.hostName)
|
|
@@ -187,6 +201,16 @@ export function SessionBrowser({
|
|
|
187
201
|
/>
|
|
188
202
|
</FilterRow>
|
|
189
203
|
) : null}
|
|
204
|
+
{projects.length > 1 ? (
|
|
205
|
+
<FilterRow label='Project'>
|
|
206
|
+
<FacetSelect
|
|
207
|
+
label='Project'
|
|
208
|
+
value={config.projects ?? []}
|
|
209
|
+
options={projects.map((p) => ({ value: p.key, label: p.label }))}
|
|
210
|
+
onChange={(next) => set({ projects: next })}
|
|
211
|
+
/>
|
|
212
|
+
</FilterRow>
|
|
213
|
+
) : null}
|
|
190
214
|
<FilterRow label='Group'>
|
|
191
215
|
<OneOfSelect
|
|
192
216
|
label='Group'
|
|
@@ -195,6 +219,7 @@ export function SessionBrowser({
|
|
|
195
219
|
{ value: 'none', label: 'No grouping' },
|
|
196
220
|
{ value: 'state', label: 'By state' },
|
|
197
221
|
{ value: 'adapter', label: 'By engine' },
|
|
222
|
+
...(projects.length > 1 ? [{ value: 'project' as const, label: 'By project' }] : []),
|
|
198
223
|
...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'By gateway' }] : []),
|
|
199
224
|
]}
|
|
200
225
|
onChange={(groupBy) => set({ groupBy: groupBy as GroupBy })}
|
|
@@ -208,6 +233,7 @@ export function SessionBrowser({
|
|
|
208
233
|
{ value: 'recent', label: 'Recent' },
|
|
209
234
|
{ value: 'name', label: 'Name' },
|
|
210
235
|
{ value: 'state', label: 'State' },
|
|
236
|
+
...(projects.length > 1 ? [{ value: 'project' as const, label: 'Project' }] : []),
|
|
211
237
|
...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'Gateway' }] : []),
|
|
212
238
|
]}
|
|
213
239
|
onChange={(sortBy) => set({ sortBy: sortBy as SortBy })}
|
|
@@ -253,7 +279,16 @@ export function SessionBrowser({
|
|
|
253
279
|
{groups.map((group) => (
|
|
254
280
|
<div key={group.key} className='flex flex-col gap-1'>
|
|
255
281
|
{config.groupBy !== 'none' && group.label ? (
|
|
256
|
-
<div className='flex items-
|
|
282
|
+
<div className='flex items-center gap-2 px-3 text-label font-medium text-fg-4'>
|
|
283
|
+
{/* Only the project facet has a mark of its own, and a group
|
|
284
|
+
IS one project root, so the first row is a fair source. */}
|
|
285
|
+
{config.groupBy === 'project' ? (
|
|
286
|
+
<ProjectIcon
|
|
287
|
+
icon={group.rows[0]?.info.project?.icon}
|
|
288
|
+
src={iconSrcOf(group.rows[0], projectIcons)}
|
|
289
|
+
name={group.label}
|
|
290
|
+
/>
|
|
291
|
+
) : null}
|
|
257
292
|
<span className='uppercase tracking-wide'>{group.label}</span>
|
|
258
293
|
<span className='text-fg-4/70'>{group.rows.length}</span>
|
|
259
294
|
</div>
|
|
@@ -264,6 +299,8 @@ export function SessionBrowser({
|
|
|
264
299
|
row={row}
|
|
265
300
|
active={row.info.id === activeId}
|
|
266
301
|
showGateway={gateways.length > 1}
|
|
302
|
+
showProject={config.groupBy !== 'project'}
|
|
303
|
+
projectIcons={projectIcons}
|
|
267
304
|
onSelect={onSelect}
|
|
268
305
|
onDelete={onDelete}
|
|
269
306
|
onRename={onRename}
|
|
@@ -277,10 +314,27 @@ export function SessionBrowser({
|
|
|
277
314
|
)
|
|
278
315
|
}
|
|
279
316
|
|
|
317
|
+
/** The bytes for a row's project icon, if it has an image one and the caller has
|
|
318
|
+
* fetched it yet. Shared by the row and its group header so the two cannot draw
|
|
319
|
+
* different pictures for one project. */
|
|
320
|
+
function iconSrcOf(
|
|
321
|
+
row: SessionRow | undefined,
|
|
322
|
+
icons: Record<string, string> | undefined,
|
|
323
|
+
): string | undefined {
|
|
324
|
+
const icon = row?.info.project?.icon
|
|
325
|
+
return icon?.type === 'image' ? icons?.[icon.hash] : undefined
|
|
326
|
+
}
|
|
327
|
+
|
|
280
328
|
interface SessionRowItemProps {
|
|
281
329
|
row: SessionRow
|
|
282
330
|
active?: boolean
|
|
283
331
|
showGateway?: boolean
|
|
332
|
+
/** False when the list is already grouped by project — the header has said
|
|
333
|
+
* the name, so the slot goes back to the cwd's basename, which inside a
|
|
334
|
+
* project group is the one thing the header cannot say. The rule `showGateway`
|
|
335
|
+
* follows one facet over. */
|
|
336
|
+
showProject?: boolean
|
|
337
|
+
projectIcons?: Record<string, string>
|
|
284
338
|
onSelect?: (row: SessionRow) => void
|
|
285
339
|
onDelete?: (row: SessionRow) => void
|
|
286
340
|
onRename?: (row: SessionRow, title: string) => void
|
|
@@ -290,6 +344,8 @@ function SessionRowItem({
|
|
|
290
344
|
row,
|
|
291
345
|
active,
|
|
292
346
|
showGateway,
|
|
347
|
+
showProject = true,
|
|
348
|
+
projectIcons,
|
|
293
349
|
onSelect,
|
|
294
350
|
onDelete,
|
|
295
351
|
onRename,
|
|
@@ -300,13 +356,23 @@ function SessionRowItem({
|
|
|
300
356
|
// What it is and what it has spent, in one line — the same set the extension
|
|
301
357
|
// shows, joined the same way, so the two lists read as one product.
|
|
302
358
|
const folder = info.cwd.split('/').filter(Boolean).pop() ?? info.cwd
|
|
359
|
+
// protocol's own spelling, so this row, the group header above it and the
|
|
360
|
+
// project facet cannot disagree about what a project is called. It falls back
|
|
361
|
+
// to exactly the basename this line drew before the feature existed.
|
|
362
|
+
const project = showProject ? projectLabel(row) : folder
|
|
363
|
+
const projectIcon = showProject ? info.project?.icon : undefined
|
|
303
364
|
const details = [
|
|
304
365
|
showGateway ? row.hostName : undefined,
|
|
305
366
|
friendlyModel(info.model),
|
|
306
|
-
|
|
367
|
+
project,
|
|
307
368
|
info.profile ? `@${info.profile}` : undefined,
|
|
308
369
|
formatCost(info.totalCostUsd),
|
|
309
370
|
].filter(Boolean)
|
|
371
|
+
// Where the icon goes: immediately before the project's own name, wherever
|
|
372
|
+
// that landed in the joined line. Split rather than interleaved as nodes,
|
|
373
|
+
// because everything here is one truncating mono run and a flex of pieces
|
|
374
|
+
// would each shrink a little and leave several half-words.
|
|
375
|
+
const cut = details.indexOf(project)
|
|
310
376
|
|
|
311
377
|
return (
|
|
312
378
|
<div
|
|
@@ -371,7 +437,22 @@ function SessionRowItem({
|
|
|
371
437
|
type='button'
|
|
372
438
|
tabIndex={-1}
|
|
373
439
|
className='min-w-0 flex-1 truncate text-left font-mono outline-none'>
|
|
374
|
-
{
|
|
440
|
+
{cut < 0 ? (
|
|
441
|
+
details.join(' · ')
|
|
442
|
+
) : (
|
|
443
|
+
<>
|
|
444
|
+
{details.slice(0, cut).map((part) => `${part} · `)}
|
|
445
|
+
<ProjectIcon
|
|
446
|
+
icon={projectIcon}
|
|
447
|
+
src={iconSrcOf(row, projectIcons)}
|
|
448
|
+
name={project}
|
|
449
|
+
/* Nudged onto the text baseline: a 12px glyph box against an
|
|
450
|
+
11px line sits a hair proud without it. */
|
|
451
|
+
className='mr-1 align-[-0.2em]'
|
|
452
|
+
/>
|
|
453
|
+
{details.slice(cut).join(' · ')}
|
|
454
|
+
</>
|
|
455
|
+
)}
|
|
375
456
|
</button>
|
|
376
457
|
{onRename && !editing ? (
|
|
377
458
|
<Button
|