@workerdeck/ui 0.9.0 → 0.12.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 +81 -3
- package/build/SessionPanel-Dy9lQrOV.d.mts +319 -0
- package/build/SessionPanel-NQ8ksCfj.mjs +8474 -0
- package/build/SessionPanel-NQ8ksCfj.mjs.map +1 -0
- package/build/format-DqR56Y8l.mjs +162 -0
- package/build/format-DqR56Y8l.mjs.map +1 -0
- package/build/format-ljc3lKpA.d.mts +59 -0
- package/build/format.d.mts +66 -0
- package/build/format.mjs +119 -0
- package/build/format.mjs.map +1 -0
- package/build/index.d.mts +671 -88
- package/build/index.mjs +387 -5160
- package/build/index.mjs.map +1 -1
- package/build/workspace.d.mts +226 -0
- package/build/workspace.mjs +861 -0
- package/build/workspace.mjs.map +1 -0
- package/package.json +22 -4
- package/src/components/agent/CodeEditor.tsx +300 -0
- package/src/components/agent/Composer.tsx +522 -87
- package/src/components/agent/ContextDialog.tsx +99 -0
- package/src/components/agent/Conversation.tsx +11 -3
- package/src/components/agent/EditorTabs.tsx +165 -0
- package/src/components/agent/FileCard.tsx +26 -0
- package/src/components/agent/FileTree.tsx +287 -0
- package/src/components/agent/FileViewer.tsx +148 -0
- package/src/components/agent/HostFilesDialog.tsx +218 -0
- package/src/components/agent/Loader.tsx +82 -14
- package/src/components/agent/McpDialog.tsx +363 -0
- package/src/components/agent/Message.tsx +51 -17
- package/src/components/agent/ModelSelect.tsx +34 -6
- package/src/components/agent/PermissionModeSelect.tsx +133 -22
- package/src/components/agent/PermissionPrompt.tsx +164 -6
- package/src/components/agent/PromptTokenText.tsx +39 -0
- package/src/components/agent/QuestionPrompt.tsx +122 -0
- package/src/components/agent/Reasoning.tsx +20 -5
- package/src/components/agent/Response.tsx +128 -0
- package/src/components/agent/SessionBrowser.tsx +428 -0
- package/src/components/agent/SessionEmptyState.tsx +65 -0
- package/src/components/agent/SessionInfoDialog.tsx +163 -0
- package/src/components/agent/SessionPanel.tsx +783 -91
- package/src/components/agent/SessionWorkspace.tsx +317 -0
- package/src/components/agent/SkillsDialog.tsx +195 -0
- package/src/components/agent/StatusBar.tsx +85 -18
- package/src/components/agent/ToolCallCard.tsx +252 -30
- package/src/components/agent/Transcript.tsx +513 -30
- package/src/components/agent/UsageDialog.tsx +168 -0
- package/src/components/agent/line-prompt.tsx +249 -0
- package/src/components/agent/pulse.tsx +60 -0
- package/src/components/agent/transcript-variant.tsx +123 -0
- package/src/components/prompt-area/prompt-area-engine.ts +53 -0
- package/src/components/prompt-area/types.ts +15 -0
- package/src/components/prompt-area/use-prompt-area.ts +20 -0
- package/src/components/ui/CodeBlock.tsx +40 -2
- package/src/components/ui/CopyButton.tsx +28 -3
- package/src/components/ui/Dialog.tsx +92 -0
- package/src/components/ui/Menu.tsx +55 -0
- package/src/components/ui/Splitter.tsx +133 -0
- package/src/components/ui/Tooltip.tsx +22 -5
- package/src/format.ts +11 -0
- package/src/index.ts +67 -2
- package/src/lib/clipboard.ts +56 -0
- package/src/lib/format.ts +114 -0
- package/src/lib/status.ts +124 -0
- package/src/lib/tool-icon.ts +96 -0
- package/src/workspace.ts +28 -0
|
@@ -1,6 +1,131 @@
|
|
|
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
|
+
}
|
|
4
129
|
|
|
5
130
|
export interface ResponseProps {
|
|
6
131
|
children: string
|
|
@@ -13,13 +138,16 @@ export interface ResponseProps {
|
|
|
13
138
|
* highlighted with shiki (dual theme follows [data-theme] through the dark: variant). */
|
|
14
139
|
export const Response = memo(
|
|
15
140
|
function Response({ children, streaming, className }: ResponseProps) {
|
|
141
|
+
const lines = useLines()
|
|
16
142
|
return (
|
|
17
143
|
<Streamdown
|
|
18
144
|
mode={streaming ? 'streaming' : 'static'}
|
|
19
145
|
parseIncompleteMarkdown={streaming}
|
|
20
146
|
shikiTheme={['github-light', 'github-dark']}
|
|
147
|
+
icons={lines ? TERMINAL_ICONS : undefined}
|
|
21
148
|
className={cn(
|
|
22
149
|
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
|
150
|
+
lines && TERMINAL_PROSE,
|
|
23
151
|
className,
|
|
24
152
|
)}>
|
|
25
153
|
{children}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { Pencil, Search, Trash2, X } from 'lucide-react'
|
|
3
|
+
import {
|
|
4
|
+
STATE_LABELS,
|
|
5
|
+
STATE_ORDER,
|
|
6
|
+
adaptersOf,
|
|
7
|
+
clearFilters,
|
|
8
|
+
filterRows,
|
|
9
|
+
groupRows,
|
|
10
|
+
hasFacetFilter,
|
|
11
|
+
sessionLabel,
|
|
12
|
+
subsetSummary,
|
|
13
|
+
} from '@workerdeck/protocol'
|
|
14
|
+
import type {
|
|
15
|
+
GroupBy,
|
|
16
|
+
SessionRow,
|
|
17
|
+
SessionState,
|
|
18
|
+
SortBy,
|
|
19
|
+
ViewConfig,
|
|
20
|
+
WorkspaceScope,
|
|
21
|
+
} from '@workerdeck/protocol'
|
|
22
|
+
import { Badge } from '../ui/Badge.tsx'
|
|
23
|
+
import { Button } from '../ui/Button.tsx'
|
|
24
|
+
import { Input } from '../ui/Input.tsx'
|
|
25
|
+
import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
|
|
26
|
+
import { cn } from '../../lib/utils.ts'
|
|
27
|
+
import { formatCost, formatRelativeTime } from '../../lib/format.ts'
|
|
28
|
+
import { STATUS_META } from './status.ts'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A sessions list with the affordances a list of thirty needs: search, facets,
|
|
32
|
+
* grouping, sorting, unread counts, and one honest line about what is hidden.
|
|
33
|
+
*
|
|
34
|
+
* The *rules* are `@workerdeck/protocol`'s (`filterRows`/`groupRows`/
|
|
35
|
+
* `subsetSummary`), not this component's — the VS Code sidebar renders the same
|
|
36
|
+
* model with workbench chrome, its activity-bar badge counts the same rows this
|
|
37
|
+
* would show, and iOS mirrors them in Swift. What lives here is the styled
|
|
38
|
+
* rendering of that model, so a host that wants the dashboard's look gets it
|
|
39
|
+
* without reimplementing the model behind it.
|
|
40
|
+
*
|
|
41
|
+
* `SessionList` remains beside this for the plain case (a fixed set of rows, no
|
|
42
|
+
* controls); this is what you reach for when the list is the screen.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
export interface SessionBrowserProps {
|
|
46
|
+
rows: SessionRow[]
|
|
47
|
+
config: ViewConfig
|
|
48
|
+
onConfigChange: (config: ViewConfig) => void
|
|
49
|
+
/** The host's own folders, if it has such a notion. Absent — a dashboard, a
|
|
50
|
+
* phone — makes the scope filter genuinely inert rather than empty. */
|
|
51
|
+
scope?: WorkspaceScope
|
|
52
|
+
activeId?: string
|
|
53
|
+
onSelect?: (row: SessionRow) => void
|
|
54
|
+
onDelete?: (row: SessionRow) => void
|
|
55
|
+
/**
|
|
56
|
+
* Rename, from the row's pencil. Empty string restores the derived title. A
|
|
57
|
+
* gateway edit (`PATCH /sessions/:id`), never a local override — every client
|
|
58
|
+
* should see the same name. Omit to make titles read-only.
|
|
59
|
+
*
|
|
60
|
+
* A hover affordance rather than the extension's double-click-the-title,
|
|
61
|
+
* because here a single click on the row navigates: the *first* click of a
|
|
62
|
+
* double-click would have already left the page.
|
|
63
|
+
*/
|
|
64
|
+
onRename?: (row: SessionRow, title: string) => void
|
|
65
|
+
/** Rendered when nothing at all exists (as opposed to nothing matching). */
|
|
66
|
+
emptyState?: React.ReactNode
|
|
67
|
+
className?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function SessionBrowser({
|
|
71
|
+
rows,
|
|
72
|
+
config,
|
|
73
|
+
onConfigChange,
|
|
74
|
+
scope,
|
|
75
|
+
activeId,
|
|
76
|
+
onSelect,
|
|
77
|
+
onDelete,
|
|
78
|
+
onRename,
|
|
79
|
+
emptyState,
|
|
80
|
+
className,
|
|
81
|
+
}: SessionBrowserProps) {
|
|
82
|
+
const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
|
|
83
|
+
const groups = useMemo(() => groupRows(visible, config), [visible, config])
|
|
84
|
+
const subset = subsetSummary(config, scope, visible.length, rows.length)
|
|
85
|
+
// Derived, not enumerated: a new engine or a new gateway needs no change here,
|
|
86
|
+
// and a facet with one possible value is not a choice worth showing.
|
|
87
|
+
const adapters = useMemo(() => adaptersOf(rows), [rows])
|
|
88
|
+
const gateways = useMemo(() => {
|
|
89
|
+
const seen = new Map<string, string>()
|
|
90
|
+
for (const row of rows) seen.set(row.hostId, row.hostName)
|
|
91
|
+
return [...seen].map(([id, name]) => ({ id, name }))
|
|
92
|
+
}, [rows])
|
|
93
|
+
|
|
94
|
+
const set = (patch: Partial<ViewConfig>) => onConfigChange({ ...config, ...patch })
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div data-slot='session-browser' className={cn('flex flex-col gap-3', className)}>
|
|
98
|
+
<div className='flex flex-wrap items-center gap-2'>
|
|
99
|
+
<div className='relative min-w-48 flex-1'>
|
|
100
|
+
<Search className='pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-fg-4' />
|
|
101
|
+
<Input
|
|
102
|
+
value={config.search}
|
|
103
|
+
onChange={(e) => set({ search: e.target.value })}
|
|
104
|
+
placeholder='Search sessions'
|
|
105
|
+
aria-label='Search sessions'
|
|
106
|
+
className='pl-8'
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
<FacetSelect
|
|
110
|
+
label='State'
|
|
111
|
+
value={config.states}
|
|
112
|
+
options={STATE_ORDER.map((s) => ({ value: s, label: STATE_LABELS[s] }))}
|
|
113
|
+
onChange={(states) => set({ states: states as SessionState[] })}
|
|
114
|
+
/>
|
|
115
|
+
{adapters.length > 1 ? (
|
|
116
|
+
<FacetSelect
|
|
117
|
+
label='Engine'
|
|
118
|
+
value={config.adapters}
|
|
119
|
+
options={adapters.map((a) => ({ value: a, label: a }))}
|
|
120
|
+
onChange={(adapters) => set({ adapters })}
|
|
121
|
+
/>
|
|
122
|
+
) : null}
|
|
123
|
+
{gateways.length > 1 ? (
|
|
124
|
+
<FacetSelect
|
|
125
|
+
label='Gateway'
|
|
126
|
+
value={config.gateways}
|
|
127
|
+
options={gateways.map((g) => ({ value: g.id, label: g.name }))}
|
|
128
|
+
onChange={(gateways) => set({ gateways })}
|
|
129
|
+
/>
|
|
130
|
+
) : null}
|
|
131
|
+
<OneOfSelect
|
|
132
|
+
label='Group'
|
|
133
|
+
value={config.groupBy}
|
|
134
|
+
options={[
|
|
135
|
+
{ value: 'none', label: 'No grouping' },
|
|
136
|
+
{ value: 'state', label: 'By state' },
|
|
137
|
+
{ value: 'adapter', label: 'By engine' },
|
|
138
|
+
...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'By gateway' }] : []),
|
|
139
|
+
]}
|
|
140
|
+
onChange={(groupBy) => set({ groupBy: groupBy as GroupBy })}
|
|
141
|
+
/>
|
|
142
|
+
<OneOfSelect
|
|
143
|
+
label='Sort'
|
|
144
|
+
value={config.sortBy}
|
|
145
|
+
options={[
|
|
146
|
+
{ value: 'recent', label: 'Recent' },
|
|
147
|
+
{ value: 'name', label: 'Name' },
|
|
148
|
+
{ value: 'state', label: 'State' },
|
|
149
|
+
...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'Gateway' }] : []),
|
|
150
|
+
]}
|
|
151
|
+
onChange={(sortBy) => set({ sortBy: sortBy as SortBy })}
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{subset ? (
|
|
156
|
+
<div className='flex items-center gap-2 text-label text-fg-4'>
|
|
157
|
+
<span>
|
|
158
|
+
{subset.shown} of {subset.total}
|
|
159
|
+
{subset.causes.length ? ` · ${subset.causes.join(' · ')}` : null}
|
|
160
|
+
</span>
|
|
161
|
+
<button
|
|
162
|
+
type='button'
|
|
163
|
+
className='text-fg-3 underline underline-offset-2 hover:text-fg-1'
|
|
164
|
+
onClick={() => onConfigChange(clearFilters(config))}>
|
|
165
|
+
Show all
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
) : null}
|
|
169
|
+
|
|
170
|
+
{rows.length === 0 ? (
|
|
171
|
+
(emptyState ?? <Empty>No sessions yet.</Empty>)
|
|
172
|
+
) : visible.length === 0 ? (
|
|
173
|
+
// Two different dead ends, two different ways out. "Nothing matches" is
|
|
174
|
+
// a filter someone set; anything else is the state of the world.
|
|
175
|
+
<Empty>
|
|
176
|
+
{hasFacetFilter(config) ? (
|
|
177
|
+
<>
|
|
178
|
+
No sessions match.{' '}
|
|
179
|
+
<button
|
|
180
|
+
type='button'
|
|
181
|
+
className='underline underline-offset-2 hover:text-fg-1'
|
|
182
|
+
onClick={() => onConfigChange(clearFilters(config))}>
|
|
183
|
+
Clear filters
|
|
184
|
+
</button>
|
|
185
|
+
</>
|
|
186
|
+
) : (
|
|
187
|
+
'No sessions here.'
|
|
188
|
+
)}
|
|
189
|
+
</Empty>
|
|
190
|
+
) : (
|
|
191
|
+
<div className='flex flex-col gap-4'>
|
|
192
|
+
{groups.map((group) => (
|
|
193
|
+
<div key={group.key} className='flex flex-col gap-1'>
|
|
194
|
+
{config.groupBy !== 'none' && group.label ? (
|
|
195
|
+
<div className='flex items-baseline gap-2 px-2.5 text-label font-medium text-fg-4'>
|
|
196
|
+
<span className='uppercase tracking-wide'>{group.label}</span>
|
|
197
|
+
<span className='text-fg-4/70'>{group.rows.length}</span>
|
|
198
|
+
</div>
|
|
199
|
+
) : null}
|
|
200
|
+
{group.rows.map((row) => (
|
|
201
|
+
<SessionRowItem
|
|
202
|
+
key={`${row.hostId}:${row.info.id}`}
|
|
203
|
+
row={row}
|
|
204
|
+
active={row.info.id === activeId}
|
|
205
|
+
showGateway={gateways.length > 1}
|
|
206
|
+
onSelect={onSelect}
|
|
207
|
+
onDelete={onDelete}
|
|
208
|
+
onRename={onRename}
|
|
209
|
+
/>
|
|
210
|
+
))}
|
|
211
|
+
</div>
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function Empty({ children }: { children: React.ReactNode }) {
|
|
220
|
+
return <div className='px-2.5 py-6 text-center text-body-sm text-fg-4'>{children}</div>
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface SessionRowItemProps {
|
|
224
|
+
row: SessionRow
|
|
225
|
+
active?: boolean
|
|
226
|
+
showGateway?: boolean
|
|
227
|
+
onSelect?: (row: SessionRow) => void
|
|
228
|
+
onDelete?: (row: SessionRow) => void
|
|
229
|
+
onRename?: (row: SessionRow, title: string) => void
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function SessionRowItem({
|
|
233
|
+
row,
|
|
234
|
+
active,
|
|
235
|
+
showGateway,
|
|
236
|
+
onSelect,
|
|
237
|
+
onDelete,
|
|
238
|
+
onRename,
|
|
239
|
+
}: SessionRowItemProps) {
|
|
240
|
+
const { info } = row
|
|
241
|
+
const meta = STATUS_META[info.status]
|
|
242
|
+
const [editing, setEditing] = useState(false)
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div
|
|
246
|
+
data-slot='session-row'
|
|
247
|
+
data-active={active || undefined}
|
|
248
|
+
className={cn(
|
|
249
|
+
'group flex w-full items-center gap-2 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors',
|
|
250
|
+
active ? 'border-border bg-surface' : 'hover:bg-surface-hover',
|
|
251
|
+
)}>
|
|
252
|
+
<div className='min-w-0 flex-1'>
|
|
253
|
+
<div className='flex items-center gap-2'>
|
|
254
|
+
{/* The editor replaces the link rather than sitting inside it — an
|
|
255
|
+
input nested in a button is invalid, and disabling the button to
|
|
256
|
+
protect the edit disables the field with it. */}
|
|
257
|
+
{editing && onRename ? (
|
|
258
|
+
<NameEditor
|
|
259
|
+
initial={info.title ?? ''}
|
|
260
|
+
onCommit={(title) => {
|
|
261
|
+
setEditing(false)
|
|
262
|
+
onRename(row, title)
|
|
263
|
+
}}
|
|
264
|
+
onCancel={() => setEditing(false)}
|
|
265
|
+
/>
|
|
266
|
+
) : (
|
|
267
|
+
<button
|
|
268
|
+
type='button'
|
|
269
|
+
onClick={() => onSelect?.(row)}
|
|
270
|
+
className='min-w-0 truncate text-left text-body-sm font-medium text-fg-1 outline-none'>
|
|
271
|
+
{sessionLabel(info)}
|
|
272
|
+
</button>
|
|
273
|
+
)}
|
|
274
|
+
<Badge variant={meta.variant} dot className='shrink-0'>
|
|
275
|
+
{meta.label}
|
|
276
|
+
</Badge>
|
|
277
|
+
{row.unseen > 0 ? (
|
|
278
|
+
// Transcript rows since this session was last on screen — the same
|
|
279
|
+
// unit the VS Code badge counts, because turns undercount badly.
|
|
280
|
+
<Badge variant='accent' className='shrink-0' title={`${row.unseen} new`}>
|
|
281
|
+
{row.unseen}
|
|
282
|
+
</Badge>
|
|
283
|
+
) : null}
|
|
284
|
+
</div>
|
|
285
|
+
<button
|
|
286
|
+
type='button'
|
|
287
|
+
onClick={() => !editing && onSelect?.(row)}
|
|
288
|
+
className='mt-0.5 flex w-full items-center gap-2 text-left font-mono text-label text-fg-4 outline-none'>
|
|
289
|
+
<span className='truncate'>{info.cwd}</span>
|
|
290
|
+
{showGateway ? <span className='shrink-0'>{row.hostName}</span> : null}
|
|
291
|
+
{info.profile ? <span className='shrink-0'>@{info.profile}</span> : null}
|
|
292
|
+
<span className='shrink-0'>{formatCost(info.totalCostUsd)}</span>
|
|
293
|
+
<span className='shrink-0'>
|
|
294
|
+
{formatRelativeTime(info.lastActivityAt ?? info.createdAt)}
|
|
295
|
+
</span>
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
{onRename && !editing ? (
|
|
299
|
+
<Button
|
|
300
|
+
variant='ghost'
|
|
301
|
+
size='icon-sm'
|
|
302
|
+
aria-label='Rename session'
|
|
303
|
+
className='opacity-0 transition-opacity group-hover:opacity-100'
|
|
304
|
+
onClick={() => setEditing(true)}>
|
|
305
|
+
<Pencil className='size-3.5 text-fg-3' />
|
|
306
|
+
</Button>
|
|
307
|
+
) : null}
|
|
308
|
+
{onDelete ? (
|
|
309
|
+
<Button
|
|
310
|
+
variant='ghost'
|
|
311
|
+
size='icon-sm'
|
|
312
|
+
aria-label='Close session'
|
|
313
|
+
className='opacity-0 transition-opacity group-hover:opacity-100'
|
|
314
|
+
onClick={() => onDelete(row)}>
|
|
315
|
+
<Trash2 className='size-3.5 text-fg-3' />
|
|
316
|
+
</Button>
|
|
317
|
+
) : null}
|
|
318
|
+
</div>
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Inline rename. Enter commits, Escape cancels, blur commits — but only a blur
|
|
324
|
+
* that is still inside this document: switching windows must not silently commit,
|
|
325
|
+
* nor kill the editor in the frame it opened.
|
|
326
|
+
*/
|
|
327
|
+
function NameEditor({
|
|
328
|
+
initial,
|
|
329
|
+
onCommit,
|
|
330
|
+
onCancel,
|
|
331
|
+
}: {
|
|
332
|
+
initial: string
|
|
333
|
+
onCommit: (title: string) => void
|
|
334
|
+
onCancel: () => void
|
|
335
|
+
}) {
|
|
336
|
+
const [value, setValue] = useState(initial)
|
|
337
|
+
const ref = useRef<HTMLInputElement>(null)
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
ref.current?.select()
|
|
340
|
+
}, [])
|
|
341
|
+
return (
|
|
342
|
+
<input
|
|
343
|
+
ref={ref}
|
|
344
|
+
value={value}
|
|
345
|
+
autoFocus
|
|
346
|
+
aria-label='Session name'
|
|
347
|
+
// The row is a button; a click in here must not select the session.
|
|
348
|
+
onClick={(e) => e.stopPropagation()}
|
|
349
|
+
onChange={(e) => setValue(e.target.value)}
|
|
350
|
+
onBlur={() => (document.hasFocus() ? onCommit(value.trim()) : undefined)}
|
|
351
|
+
onKeyDown={(e) => {
|
|
352
|
+
if (e.key === 'Enter') onCommit(value.trim())
|
|
353
|
+
else if (e.key === 'Escape') onCancel()
|
|
354
|
+
e.stopPropagation()
|
|
355
|
+
}}
|
|
356
|
+
className='-my-0.5 min-w-0 flex-1 rounded-sm border border-ring bg-bg px-1 py-0.5 text-body-sm font-medium text-fg-1 outline-none'
|
|
357
|
+
/>
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A multi-select facet. Empty = no filter, which is why the trigger reads the
|
|
362
|
+
* facet's name rather than "All": nothing is being excluded. */
|
|
363
|
+
function FacetSelect({
|
|
364
|
+
label,
|
|
365
|
+
value,
|
|
366
|
+
options,
|
|
367
|
+
onChange,
|
|
368
|
+
}: {
|
|
369
|
+
label: string
|
|
370
|
+
value: string[]
|
|
371
|
+
options: { value: string; label: string }[]
|
|
372
|
+
onChange: (value: string[]) => void
|
|
373
|
+
}) {
|
|
374
|
+
return (
|
|
375
|
+
<div className='flex items-center gap-1'>
|
|
376
|
+
<Select multiple value={value} onValueChange={(v) => onChange(v as string[])}>
|
|
377
|
+
<SelectTrigger aria-label={label} className='min-w-28'>
|
|
378
|
+
<SelectValue>
|
|
379
|
+
{value.length === 0
|
|
380
|
+
? label
|
|
381
|
+
: value.length === 1
|
|
382
|
+
? (options.find((o) => o.value === value[0])?.label ?? label)
|
|
383
|
+
: `${label} · ${value.length}`}
|
|
384
|
+
</SelectValue>
|
|
385
|
+
</SelectTrigger>
|
|
386
|
+
<SelectContent>
|
|
387
|
+
{options.map((option) => (
|
|
388
|
+
<SelectItem key={option.value} value={option.value}>
|
|
389
|
+
<SelectItemText>{option.label}</SelectItemText>
|
|
390
|
+
</SelectItem>
|
|
391
|
+
))}
|
|
392
|
+
</SelectContent>
|
|
393
|
+
</Select>
|
|
394
|
+
{value.length > 0 ? (
|
|
395
|
+
<Button variant='ghost' size='icon-sm' aria-label={`Clear ${label}`} onClick={() => onChange([])}>
|
|
396
|
+
<X className='size-3 text-fg-4' />
|
|
397
|
+
</Button>
|
|
398
|
+
) : null}
|
|
399
|
+
</div>
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function OneOfSelect({
|
|
404
|
+
label,
|
|
405
|
+
value,
|
|
406
|
+
options,
|
|
407
|
+
onChange,
|
|
408
|
+
}: {
|
|
409
|
+
label: string
|
|
410
|
+
value: string
|
|
411
|
+
options: readonly { value: string; label: string }[]
|
|
412
|
+
onChange: (value: string) => void
|
|
413
|
+
}) {
|
|
414
|
+
return (
|
|
415
|
+
<Select value={value} onValueChange={(v) => onChange(v as string)}>
|
|
416
|
+
<SelectTrigger aria-label={label} className='min-w-28'>
|
|
417
|
+
<SelectValue>{options.find((o) => o.value === value)?.label ?? label}</SelectValue>
|
|
418
|
+
</SelectTrigger>
|
|
419
|
+
<SelectContent>
|
|
420
|
+
{options.map((option) => (
|
|
421
|
+
<SelectItem key={option.value} value={option.value}>
|
|
422
|
+
<SelectItemText>{option.label}</SelectItemText>
|
|
423
|
+
</SelectItem>
|
|
424
|
+
))}
|
|
425
|
+
</SelectContent>
|
|
426
|
+
</Select>
|
|
427
|
+
)
|
|
428
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { AtSign, MessageSquareText, SlashSquare, Sparkles, Terminal } from 'lucide-react'
|
|
2
|
+
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
|
|
4
|
+
export interface SessionEmptyStateProps {
|
|
5
|
+
cwd?: string
|
|
6
|
+
/** Whether `/command` completion is live yet — the CLI reports its commands a
|
|
7
|
+
* beat after the session starts, and promising a feature that isn't wired up
|
|
8
|
+
* yet is worse than not mentioning it. */
|
|
9
|
+
hasCommands?: boolean
|
|
10
|
+
/** Whether the engine has reported skills the `/` popover can offer. Its own
|
|
11
|
+
* flag, not a variant of `hasCommands`: what `/` does differs — a command is
|
|
12
|
+
* submitted, a skill is typed for you to edit — and an engine can have one
|
|
13
|
+
* without the other. */
|
|
14
|
+
hasSkills?: boolean
|
|
15
|
+
/** Whether this gateway serves `@file` search for the session's directory. */
|
|
16
|
+
canBrowseFiles?: boolean
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a session shows before it has said anything: where the agent is sitting,
|
|
22
|
+
* and what the composer accepts beyond prose.
|
|
23
|
+
*
|
|
24
|
+
* Every hint is conditional on the affordance actually existing — an engine
|
|
25
|
+
* without slash commands, or a gateway without host files, is not told about
|
|
26
|
+
* them. Deliberately no project name (the header already carries it) and no
|
|
27
|
+
* brand mark (its geometry is inlined in several places already and they are
|
|
28
|
+
* meant to stay identical).
|
|
29
|
+
*/
|
|
30
|
+
export function SessionEmptyState({
|
|
31
|
+
cwd,
|
|
32
|
+
hasCommands,
|
|
33
|
+
hasSkills,
|
|
34
|
+
canBrowseFiles,
|
|
35
|
+
className,
|
|
36
|
+
}: SessionEmptyStateProps) {
|
|
37
|
+
const hints = [
|
|
38
|
+
{ icon: MessageSquareText, text: 'Tell the agent what to do.' },
|
|
39
|
+
// Two keys, two hints — they are different features, not two spellings of
|
|
40
|
+
// one. `$` is codex's own sigil for skills; `/` stays the CLI's commands.
|
|
41
|
+
...(hasCommands ? [{ icon: SlashSquare, text: 'Type / for the CLI’s slash commands.' }] : []),
|
|
42
|
+
...(hasSkills
|
|
43
|
+
? [{ icon: Sparkles, text: 'Type $ to draft a message for one of the agent’s skills.' }]
|
|
44
|
+
: []),
|
|
45
|
+
...(canBrowseFiles
|
|
46
|
+
? [{ icon: AtSign, text: 'Type @ to search this project’s files.' }]
|
|
47
|
+
: []),
|
|
48
|
+
]
|
|
49
|
+
return (
|
|
50
|
+
<div className={cn('flex flex-col items-center gap-4 py-10', className)}>
|
|
51
|
+
<div className='flex size-12 items-center justify-center rounded-xl bg-surface text-fg-3'>
|
|
52
|
+
<Terminal className='size-5' />
|
|
53
|
+
</div>
|
|
54
|
+
{cwd ? <p className='max-w-full truncate font-mono text-label text-fg-4'>{cwd}</p> : null}
|
|
55
|
+
<ul className='w-full max-w-sm divide-y divide-border overflow-hidden rounded-lg bg-surface'>
|
|
56
|
+
{hints.map((hint) => (
|
|
57
|
+
<li key={hint.text} className='flex items-center gap-3 px-3.5 py-2.5'>
|
|
58
|
+
<hint.icon className='size-4 shrink-0 text-fg-4' />
|
|
59
|
+
<span className='text-body-sm text-fg-3'>{hint.text}</span>
|
|
60
|
+
</li>
|
|
61
|
+
))}
|
|
62
|
+
</ul>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|