@keith9681/dsh-sidebar 0.15.2
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/LICENSE +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side Chat transcript mapping (browser half): turns a thread child's
|
|
3
|
+
* history rows (`session.history` — the generic RPC, which reads the durable
|
|
4
|
+
* log without activating the child) into compact display rows.
|
|
5
|
+
*
|
|
6
|
+
* A thread child's log starts with the ENTIRE inherited parent log as its
|
|
7
|
+
* fork seed. The mapping therefore cuts everything up to the LAST
|
|
8
|
+
* `session/end-seed` marker and maps context injections (the "Side
|
|
9
|
+
* conversation boundary" prompt, plugin-sourced context) onto a collapsible
|
|
10
|
+
* injection row, so the view shows only the thread's own conversation.
|
|
11
|
+
*
|
|
12
|
+
* Live streaming: `assistant/message` events only land when a step
|
|
13
|
+
* completes, but `assistant/chunk` events stream token-level text and
|
|
14
|
+
* reasoning deltas. The mapping accumulates both per block and supersedes
|
|
15
|
+
* them with the assembled message once it lands (settled rows).
|
|
16
|
+
*/
|
|
17
|
+
import type { SidebarHistoryEntry } from '../context-types.ts'
|
|
18
|
+
import { isContextInjectionMessage, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts'
|
|
19
|
+
|
|
20
|
+
/** One compact transcript row rendered in the thread view. `seq` is the
|
|
21
|
+
* source event's log sequence — stable row identity for React keys across
|
|
22
|
+
* polls (streaming caches ride the key, so window slides must not re-key
|
|
23
|
+
* rows). */
|
|
24
|
+
export type SidechatTranscriptRow =
|
|
25
|
+
| { kind: 'user'; seq: number; text: string }
|
|
26
|
+
/** A context injection (the side boundary prompt + the parked in-progress
|
|
27
|
+
* snapshot, or any plugin-sourced context): rendered as one collapsible
|
|
28
|
+
* row, never as a user bubble. */
|
|
29
|
+
| { kind: 'injection'; seq: number; text: string }
|
|
30
|
+
/** `settled` distinguishes an assembled message from a still-streaming
|
|
31
|
+
* chunk accumulation (streaming rows are superseded by the settle). */
|
|
32
|
+
| { kind: 'assistant'; seq: number; text: string; settled: boolean }
|
|
33
|
+
| { kind: 'reasoning'; seq: number; text: string; settled: boolean }
|
|
34
|
+
| {
|
|
35
|
+
kind: 'tool'
|
|
36
|
+
seq: number
|
|
37
|
+
name: string
|
|
38
|
+
failed: boolean
|
|
39
|
+
/** Raw arguments JSON as the model produced it. */
|
|
40
|
+
args?: string
|
|
41
|
+
/** Plain text of the paired result. */
|
|
42
|
+
resultText?: string
|
|
43
|
+
/** True while the call's result has not landed yet. */
|
|
44
|
+
executing?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Extract the visible text of a content-block list (`text` blocks verbatim,
|
|
48
|
+
* joined by blank lines); empty reads `…` so rows never render blank. */
|
|
49
|
+
export function blockText(content: readonly unknown[]): string {
|
|
50
|
+
const parts: string[] = []
|
|
51
|
+
for (const block of content) {
|
|
52
|
+
if (block === null || typeof block !== 'object') continue
|
|
53
|
+
const candidate = block as { type?: unknown; text?: unknown }
|
|
54
|
+
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
|
55
|
+
parts.push(candidate.text)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const text = parts.join('\n\n')
|
|
59
|
+
return text === '' ? '…' : text
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Cap for a tool row's one-line argument summary (display only). */
|
|
63
|
+
const ARGS_SUMMARY_MAX = 80
|
|
64
|
+
|
|
65
|
+
/** The most identifying argument keys, in priority order (bash's command,
|
|
66
|
+
* fs tools' paths, search's pattern, …). */
|
|
67
|
+
const ARGS_SUMMARY_KEYS = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'prompt'] as const
|
|
68
|
+
|
|
69
|
+
function flatTruncate(text: string): string {
|
|
70
|
+
const flat = text.replace(/\s+/g, ' ').trim()
|
|
71
|
+
return flat.length > ARGS_SUMMARY_MAX ? `${flat.slice(0, ARGS_SUMMARY_MAX - 1)}…` : flat
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One-line summary of a tool call's raw arguments JSON for the collapsed
|
|
76
|
+
* row: the first identifying string field when the JSON parses, else the
|
|
77
|
+
* flattened raw text; empty when there is nothing worth showing.
|
|
78
|
+
*/
|
|
79
|
+
export function toolArgsSummary(args: string | undefined): string {
|
|
80
|
+
if (args === undefined) return ''
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(args) as Record<string, unknown> | null
|
|
83
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
84
|
+
for (const key of ARGS_SUMMARY_KEYS) {
|
|
85
|
+
const value = parsed[key]
|
|
86
|
+
if (typeof value === 'string' && value.trim() !== '') return flatTruncate(value)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Raw text fallthrough.
|
|
91
|
+
}
|
|
92
|
+
return flatTruncate(args)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The plain text of a tool/result message (text blocks inside its
|
|
96
|
+
* `tool-result` content block). */
|
|
97
|
+
function resultTextOf(data: Record<string, unknown>): string {
|
|
98
|
+
const message = data.message as { content?: unknown } | undefined
|
|
99
|
+
const content = message?.content
|
|
100
|
+
if (!Array.isArray(content)) return ''
|
|
101
|
+
const parts: string[] = []
|
|
102
|
+
for (const block of content) {
|
|
103
|
+
if (block === null || typeof block !== 'object') continue
|
|
104
|
+
const candidate = block as { type?: unknown; content?: unknown }
|
|
105
|
+
if (candidate.type !== 'tool-result') continue
|
|
106
|
+
const inner = candidate.content
|
|
107
|
+
if (!Array.isArray(inner)) continue
|
|
108
|
+
for (const item of inner) {
|
|
109
|
+
if (item === null || typeof item !== 'object') continue
|
|
110
|
+
const textItem = item as { type?: unknown; text?: unknown }
|
|
111
|
+
if (textItem.type === 'text' && typeof textItem.text === 'string') {
|
|
112
|
+
parts.push(textItem.text)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return parts.join('\n')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Index of the last `session/end-seed` event (fork seed marker), or -1. */
|
|
120
|
+
function lastSeedEnd(events: readonly { type: string }[]): number {
|
|
121
|
+
for (let index = events.length - 1; index >= 0; index--) {
|
|
122
|
+
if (events[index]?.type === 'session/end-seed') return index
|
|
123
|
+
}
|
|
124
|
+
return -1
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Collect the thread's OWN events on first attach: walk backward from the
|
|
129
|
+
* log tail (oldest-first accumulation) until the `session/end-seed` marker
|
|
130
|
+
* surfaces, then keep everything after it.
|
|
131
|
+
*
|
|
132
|
+
* Page size matters: cold reads re-expand persisted chunk-rows into one
|
|
133
|
+
* `assistant/chunk` event per delta, so a single streamed answer can be
|
|
134
|
+
* HUNDREDS of events. A small walk window (the old 8×32 = 256 events) let
|
|
135
|
+
* earlier `tool/call` events fall out of the loaded window — the tool rows
|
|
136
|
+
* vanished on re-entry while the settled text survived. The walk therefore
|
|
137
|
+
* pages big; tail polls stay small.
|
|
138
|
+
*
|
|
139
|
+
* Exhaustion (log start reached without a marker — a thread created before
|
|
140
|
+
* seeding existed, or a pathological log) returns `seedBoundary: 0` so the
|
|
141
|
+
* caller stops re-walking and renders the window as-is.
|
|
142
|
+
*
|
|
143
|
+
* @param fetchPage - one history page (newest-first window ending at
|
|
144
|
+
* `beforeSeq`, exclusive; omit for the tail page).
|
|
145
|
+
* @param pageCap - safety bound on backward pages.
|
|
146
|
+
*/
|
|
147
|
+
export async function collectOwnEvents(
|
|
148
|
+
fetchPage: (beforeSeq?: number) => Promise<readonly SidebarHistoryEntry[]>,
|
|
149
|
+
pageCap = 40,
|
|
150
|
+
): Promise<{ seedBoundary: number; entries: SidebarHistoryEntry[] }> {
|
|
151
|
+
const collected: SidebarHistoryEntry[] = []
|
|
152
|
+
let beforeSeq: number | undefined
|
|
153
|
+
for (let page = 0; page < pageCap; page++) {
|
|
154
|
+
const events = await fetchPage(beforeSeq)
|
|
155
|
+
if (events.length === 0) {
|
|
156
|
+
// Log start reached without a marker: the window IS the whole log.
|
|
157
|
+
return { seedBoundary: 0, entries: collected }
|
|
158
|
+
}
|
|
159
|
+
const olderThan = collected.length > 0 ? collected[0]!.event.seq : undefined
|
|
160
|
+
const fresh = olderThan === undefined
|
|
161
|
+
? [...events]
|
|
162
|
+
: events.filter(entry => entry.event.seq < olderThan)
|
|
163
|
+
const seedEnd = fresh.findLastIndex(entry => entry.event.type === 'session/end-seed')
|
|
164
|
+
if (seedEnd >= 0) {
|
|
165
|
+
collected.unshift(...fresh.slice(seedEnd + 1))
|
|
166
|
+
return { seedBoundary: fresh[seedEnd]!.event.seq, entries: collected }
|
|
167
|
+
}
|
|
168
|
+
collected.unshift(...fresh)
|
|
169
|
+
if (fresh.length === 0) {
|
|
170
|
+
// The page overlaps entirely with what we have: nothing older exists.
|
|
171
|
+
return { seedBoundary: 0, entries: collected }
|
|
172
|
+
}
|
|
173
|
+
beforeSeq = fresh[0]!.event.seq
|
|
174
|
+
}
|
|
175
|
+
// Cap hit: accept the window (it is overwhelmingly the thread's own tail)
|
|
176
|
+
// rather than re-walking on every poll.
|
|
177
|
+
return { seedBoundary: 0, entries: collected }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Map a thread child's history rows onto compact transcript rows: the
|
|
182
|
+
* inherited fork seed is cut at the last `session/end-seed`, context
|
|
183
|
+
* injections map onto a collapsible injection row, `assistant/chunk`
|
|
184
|
+
* deltas accumulate into streaming rows per (turn, step, block) and are
|
|
185
|
+
* superseded by the assembled `assistant/message`, and tool invocations
|
|
186
|
+
* render one expandable line each (arguments, paired result text, failure
|
|
187
|
+
* marker; a still-executing call is marked until its result lands).
|
|
188
|
+
* @param entries - history rows (event + host-computed view) in seq order.
|
|
189
|
+
* @returns display rows in log order.
|
|
190
|
+
*/
|
|
191
|
+
export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] {
|
|
192
|
+
const events = entries.map(entry => entry.event)
|
|
193
|
+
const seedEnd = lastSeedEnd(events)
|
|
194
|
+
const rows: SidechatTranscriptRow[] = []
|
|
195
|
+
/** (turn, step, index, kind) key → index of its accumulating stream row. */
|
|
196
|
+
const streamRows = new Map<string, number>()
|
|
197
|
+
/** tool callId → index of its tool row in `rows` (result pairing). */
|
|
198
|
+
const callRows = new Map<string, number>()
|
|
199
|
+
for (let index = 0; index < events.length; index++) {
|
|
200
|
+
if (index <= seedEnd) continue
|
|
201
|
+
const event = events[index]
|
|
202
|
+
if (event === undefined) continue
|
|
203
|
+
const data = event.data as Record<string, unknown>
|
|
204
|
+
switch (event.type) {
|
|
205
|
+
case 'user/message': {
|
|
206
|
+
const text = blockText(Array.isArray(data.content) ? data.content : [])
|
|
207
|
+
// Context injections (the boundary prompt + snapshot, plugin-sourced
|
|
208
|
+
// context) collapse into an injection row; genuine user messages —
|
|
209
|
+
// including the FIRST one, which the host now delivers as its own
|
|
210
|
+
// event — render as user rows.
|
|
211
|
+
if (isContextInjectionMessage(data)) {
|
|
212
|
+
const source = data.source as { kind?: unknown } | undefined
|
|
213
|
+
// Threads logged BEFORE the host split carry boundary(+snapshot)+
|
|
214
|
+
// question in ONE 'user' message. The boundary prompt is a known
|
|
215
|
+
// constant, so the message splits THERE: the injection row keeps
|
|
216
|
+
// the prompt, the remainder (snapshot + question if any — pure
|
|
217
|
+
// question in the common case) renders as the user's real message.
|
|
218
|
+
if (source?.kind === 'user' && text.startsWith(`${SIDE_BOUNDARY_PROMPT}\n\n`)) {
|
|
219
|
+
rows.push({ kind: 'injection', seq: event.seq, text: SIDE_BOUNDARY_PROMPT })
|
|
220
|
+
const body = text.slice(SIDE_BOUNDARY_PROMPT.length + 2)
|
|
221
|
+
if (body !== '') rows.push({ kind: 'user', seq: event.seq, text: body })
|
|
222
|
+
break
|
|
223
|
+
}
|
|
224
|
+
rows.push({ kind: 'injection', seq: event.seq, text })
|
|
225
|
+
break
|
|
226
|
+
}
|
|
227
|
+
rows.push({ kind: 'user', seq: event.seq, text })
|
|
228
|
+
break
|
|
229
|
+
}
|
|
230
|
+
case 'assistant/chunk': {
|
|
231
|
+
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
|
|
232
|
+
if (chunk === null || typeof chunk !== 'object') break
|
|
233
|
+
const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null
|
|
234
|
+
if (kind === null || typeof chunk.text !== 'string' || chunk.text === '') break
|
|
235
|
+
const turn = data.turn
|
|
236
|
+
const step = data.step
|
|
237
|
+
const blockIndex = (chunk as { index?: unknown }).index
|
|
238
|
+
const key = `${String(turn)}:${String(step)}:${String(blockIndex)}:${kind}`
|
|
239
|
+
const existing = streamRows.get(key)
|
|
240
|
+
if (existing !== undefined) {
|
|
241
|
+
const row = rows[existing]
|
|
242
|
+
if (row !== undefined && row.kind === kind && !row.settled) {
|
|
243
|
+
rows[existing] = { ...row, text: row.text + chunk.text }
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
streamRows.set(key, rows.length)
|
|
247
|
+
rows.push({ kind, seq: event.seq, text: chunk.text, settled: false })
|
|
248
|
+
}
|
|
249
|
+
break
|
|
250
|
+
}
|
|
251
|
+
case 'assistant/message': {
|
|
252
|
+
const prefix = `${String(data.turn)}:${String(data.step)}:`
|
|
253
|
+
const streamed = [...streamRows.entries()]
|
|
254
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
255
|
+
.map(([, rowIndex]) => rowIndex)
|
|
256
|
+
for (const key of [...streamRows.keys()]) {
|
|
257
|
+
if (key.startsWith(prefix)) streamRows.delete(key)
|
|
258
|
+
}
|
|
259
|
+
const content = Array.isArray((data.message as { content?: unknown } | undefined)?.content)
|
|
260
|
+
? (data.message as { content: readonly unknown[] }).content
|
|
261
|
+
: []
|
|
262
|
+
const settled: SidechatTranscriptRow[] = content.flatMap((block): SidechatTranscriptRow[] => {
|
|
263
|
+
if (block === null || typeof block !== 'object') return []
|
|
264
|
+
const candidate = block as { type?: unknown; text?: unknown }
|
|
265
|
+
if (candidate.type === 'reasoning' && typeof candidate.text === 'string' && candidate.text !== '') {
|
|
266
|
+
return [{ kind: 'reasoning', seq: event.seq, text: candidate.text, settled: true }]
|
|
267
|
+
}
|
|
268
|
+
if (candidate.type === 'text' && typeof candidate.text === 'string' && candidate.text !== '') {
|
|
269
|
+
return [{ kind: 'assistant', seq: event.seq, text: candidate.text, settled: true }]
|
|
270
|
+
}
|
|
271
|
+
return []
|
|
272
|
+
})
|
|
273
|
+
if (streamed.length === 0) rows.push(...settled)
|
|
274
|
+
else rows.splice(Math.min(...streamed), streamed.length, ...settled)
|
|
275
|
+
break
|
|
276
|
+
}
|
|
277
|
+
case 'tool/call': {
|
|
278
|
+
const callId = data.callId
|
|
279
|
+
const name = typeof data.name === 'string' ? data.name : 'tool'
|
|
280
|
+
const args = typeof data.arguments === 'string' ? data.arguments : undefined
|
|
281
|
+
const rowIndex = rows.length
|
|
282
|
+
if (typeof callId === 'string') callRows.set(callId, rowIndex)
|
|
283
|
+
rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true })
|
|
284
|
+
break
|
|
285
|
+
}
|
|
286
|
+
case 'tool/result': {
|
|
287
|
+
const source = data.message as { source?: { callId?: unknown } } | undefined
|
|
288
|
+
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
|
|
289
|
+
const rowIndex = callId === undefined ? undefined : callRows.get(callId)
|
|
290
|
+
const failed = data.error !== undefined
|
|
291
|
+
const resultText = resultTextOf(data)
|
|
292
|
+
if (rowIndex !== undefined) {
|
|
293
|
+
const row = rows[rowIndex]
|
|
294
|
+
if (row !== undefined && row.kind === 'tool') {
|
|
295
|
+
rows[rowIndex] = {
|
|
296
|
+
...row,
|
|
297
|
+
failed: row.failed || failed,
|
|
298
|
+
resultText: resultText === '' ? row.resultText : resultText,
|
|
299
|
+
executing: false,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} else if (failed || resultText !== '') {
|
|
303
|
+
// Orphan result (no call row in the window): surface it so the row
|
|
304
|
+
// stays informative and expandable.
|
|
305
|
+
rows.push({
|
|
306
|
+
kind: 'tool',
|
|
307
|
+
seq: event.seq,
|
|
308
|
+
name: callId === undefined ? 'tool' : `tool:${callId.slice(0, 8)}`,
|
|
309
|
+
failed,
|
|
310
|
+
resultText: resultText === '' ? undefined : resultText,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
default: {
|
|
316
|
+
break
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return rows
|
|
321
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The split-pane workbench: renders the recursive split tree. A split lays
|
|
3
|
+
* children out row- or column-wise with draggable dividers (fractional
|
|
4
|
+
* sizes); a leaf renders its tab strip plus the active tab's content.
|
|
5
|
+
*
|
|
6
|
+
* Splitting is VSCode-style DRAG-TO-EDGE, not buttons: while dragging a tab
|
|
7
|
+
* over a pane, a drop overlay shows five zones — four edges (left/right/up/
|
|
8
|
+
* down) that split the pane with the tab in a fresh leaf, and the center
|
|
9
|
+
* that merges the tab into the pane. The tree and all operations live in
|
|
10
|
+
* state.ts; this file is pure presentation over them.
|
|
11
|
+
*/
|
|
12
|
+
import { Fragment, useEffect, useRef, useState } from 'react'
|
|
13
|
+
import type { ReactNode } from 'react'
|
|
14
|
+
import clsx from 'clsx'
|
|
15
|
+
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
|
16
|
+
import type { DropZone } from './state.ts'
|
|
17
|
+
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
|
18
|
+
import { createFrameBatcher } from './frame-batcher.ts'
|
|
19
|
+
import css from './sidebar.module.css'
|
|
20
|
+
|
|
21
|
+
/** Actions the workbench needs (bound to the store by the sidebar shell). */
|
|
22
|
+
export interface WorkbenchActions {
|
|
23
|
+
closeTab: (paneId: string, tabId: string) => void
|
|
24
|
+
activateTab: (paneId: string, tabId: string) => void
|
|
25
|
+
/** Make a pane the target of newly opened tabs (click focus). */
|
|
26
|
+
focusPane: (paneId: string) => void
|
|
27
|
+
/** VSCode drag gesture: edge → split the target pane, center → merge. */
|
|
28
|
+
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => void
|
|
29
|
+
/** Reorder within a pane (drop onto another tab inserts before it). */
|
|
30
|
+
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void
|
|
31
|
+
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One divider: pointer-capture drag translating px deltas into fractions.
|
|
35
|
+
* Deltas are incremental — each move reports the displacement since the
|
|
36
|
+
* previous move — because the store adds every reported delta to the pane
|
|
37
|
+
* sizes; a cumulative (since-pointer-down) delta would be re-added on each
|
|
38
|
+
* move and the divider would run away from the cursor.
|
|
39
|
+
*
|
|
40
|
+
* The moves are BATCHED per frame (createFrameBatcher): a pointer stream
|
|
41
|
+
* fires faster than the display refresh, and applying each move is a store
|
|
42
|
+
* reduce that re-renders both workbenches (terminals, editors, trees) per
|
|
43
|
+
* event — the visible drag lag on slower CPUs (#315). The batch accumulates
|
|
44
|
+
* the incremental deltas in a ref and applies the summed fraction at most
|
|
45
|
+
* once per frame; the sum equals what the per-event application would have
|
|
46
|
+
* produced (the reducer clamps each application, and at a settled position
|
|
47
|
+
* a clamped sum is clamped to the same boundary), so the result is
|
|
48
|
+
* indistinguishable at rest and at most one frame behind the cursor.
|
|
49
|
+
*/
|
|
50
|
+
function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
|
|
51
|
+
const { dir, onResize } = props
|
|
52
|
+
// `last` is the previous pointer position while dragging; `size` is the
|
|
53
|
+
// container size at pointer-down (constant during the drag), used to
|
|
54
|
+
// normalize the px delta into a fraction.
|
|
55
|
+
const last = useRef({ x: 0, y: 0, size: 0 })
|
|
56
|
+
const [dragging, setDragging] = useState(false)
|
|
57
|
+
const pendingDelta = useRef(0)
|
|
58
|
+
const batcher = useRef(createFrameBatcher()).current
|
|
59
|
+
useEffect(() => () => batcher.dispose(), [batcher])
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
className={clsx(css.divider, dir === 'row' ? css.dividerRow : css.dividerCol, dragging && css.dividerActive)}
|
|
64
|
+
onPointerDown={(event) => {
|
|
65
|
+
event.preventDefault()
|
|
66
|
+
event.currentTarget.setPointerCapture(event.pointerId)
|
|
67
|
+
const box = event.currentTarget.parentElement?.getBoundingClientRect()
|
|
68
|
+
last.current = {
|
|
69
|
+
x: event.clientX,
|
|
70
|
+
y: event.clientY,
|
|
71
|
+
size: box === undefined ? 1 : (dir === 'row' ? box.width : box.height),
|
|
72
|
+
}
|
|
73
|
+
setDragging(true)
|
|
74
|
+
}}
|
|
75
|
+
onPointerMove={(event) => {
|
|
76
|
+
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
|
77
|
+
const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
|
|
78
|
+
pendingDelta.current += delta
|
|
79
|
+
batcher.schedule(() => {
|
|
80
|
+
const accumulated = pendingDelta.current
|
|
81
|
+
pendingDelta.current = 0
|
|
82
|
+
if (accumulated !== 0) onResize(accumulated / Math.max(1, last.current.size))
|
|
83
|
+
})
|
|
84
|
+
last.current.x = event.clientX
|
|
85
|
+
last.current.y = event.clientY
|
|
86
|
+
}}
|
|
87
|
+
onPointerUp={(event) => {
|
|
88
|
+
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
|
89
|
+
// Flush any pending baked delta so the final pointer position lands
|
|
90
|
+
// (the release event is not a move; dropping it would lose the tail).
|
|
91
|
+
batcher.flushNow()
|
|
92
|
+
event.currentTarget.releasePointerCapture(event.pointerId)
|
|
93
|
+
setDragging(false)
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Map a pointer position inside a pane to the VSCode drop zone (25% edges). */
|
|
100
|
+
function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone {
|
|
101
|
+
const rect = pane.getBoundingClientRect()
|
|
102
|
+
if (rect.width === 0 || rect.height === 0) return 'center'
|
|
103
|
+
const x = (event.clientX - rect.left) / rect.width
|
|
104
|
+
const y = (event.clientY - rect.top) / rect.height
|
|
105
|
+
if (x < 0.25) return 'left'
|
|
106
|
+
if (x > 0.75) return 'right'
|
|
107
|
+
if (y < 0.25) return 'up'
|
|
108
|
+
if (y > 0.75) return 'down'
|
|
109
|
+
return 'center'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The icon of one openable type card (mirror of the + menu options). */
|
|
113
|
+
/**
|
|
114
|
+
* An empty pane's welcome cards: the openable types as cards, clicked to
|
|
115
|
+
* open (instead of a bare "this pane is empty" message).
|
|
116
|
+
*/
|
|
117
|
+
function PaneEmptyCards(props: {
|
|
118
|
+
newTabOptions: NewTabOption[]
|
|
119
|
+
onNewTab: (optionId: string) => void
|
|
120
|
+
}) {
|
|
121
|
+
const { newTabOptions, onNewTab } = props
|
|
122
|
+
return (
|
|
123
|
+
<div className={css.paneEmptyCards}>
|
|
124
|
+
{newTabOptions.map(option => (
|
|
125
|
+
<button
|
|
126
|
+
key={option.id}
|
|
127
|
+
type="button"
|
|
128
|
+
className={css.paneCard}
|
|
129
|
+
disabled={option.disabled === true}
|
|
130
|
+
title={option.label}
|
|
131
|
+
onClick={() => { onNewTab(option.id) }}
|
|
132
|
+
>
|
|
133
|
+
{option.icon ?? null}
|
|
134
|
+
<span>{option.label}</span>
|
|
135
|
+
</button>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A leaf: tab strip + active content + VSCode-style drop target for tabs. */
|
|
142
|
+
function LeafView(props: {
|
|
143
|
+
leaf: { id: string; tabs: SidebarTab[]; active: string | null }
|
|
144
|
+
newTabOptions: NewTabOption[]
|
|
145
|
+
actions: WorkbenchActions
|
|
146
|
+
onNewTab: (optionId: string) => void
|
|
147
|
+
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
|
148
|
+
getTabIcon?: (tab: SidebarTab) => ReactNode
|
|
149
|
+
getTabBadge?: (tab: SidebarTab) => ReactNode
|
|
150
|
+
}) {
|
|
151
|
+
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
|
|
152
|
+
const [dropZone, setDropZone] = useState<DropZone | null>(null)
|
|
153
|
+
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const clear = (): void => { setDropZone(null) }
|
|
157
|
+
window.addEventListener('dragend', clear, true)
|
|
158
|
+
window.addEventListener('drop', clear, true)
|
|
159
|
+
window.addEventListener('blur', clear)
|
|
160
|
+
return () => {
|
|
161
|
+
window.removeEventListener('dragend', clear, true)
|
|
162
|
+
window.removeEventListener('drop', clear, true)
|
|
163
|
+
window.removeEventListener('blur', clear)
|
|
164
|
+
}
|
|
165
|
+
}, [])
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
|
|
170
|
+
onPointerDown={() => { actions.focusPane(leaf.id) }}
|
|
171
|
+
onDragOver={(event) => {
|
|
172
|
+
event.preventDefault()
|
|
173
|
+
const zone = zoneAt(event, event.currentTarget)
|
|
174
|
+
setDropZone(zone)
|
|
175
|
+
}}
|
|
176
|
+
onDragLeave={(event) => {
|
|
177
|
+
// Only clear when the pointer left the pane entirely (not onto a child).
|
|
178
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
179
|
+
setDropZone(null)
|
|
180
|
+
}
|
|
181
|
+
}}
|
|
182
|
+
onDrop={(event) => {
|
|
183
|
+
event.preventDefault()
|
|
184
|
+
const zone = dropZone ?? zoneAt(event, event.currentTarget)
|
|
185
|
+
setDropZone(null)
|
|
186
|
+
const payload = parseDrag(event.dataTransfer.getData('application/x-dsh-tab'))
|
|
187
|
+
if (payload !== null) actions.moveTabToEdge(payload, leaf.id, zone)
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
{dropZone !== null && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />}
|
|
191
|
+
{/*
|
|
192
|
+
The tab strip renders even for an empty pane: the + menu must stay
|
|
193
|
+
reachable when the pane has no tabs (fresh split, or the last tab was
|
|
194
|
+
dragged out), so a new tab can always be created or dragged in.
|
|
195
|
+
*/}
|
|
196
|
+
<TabBar
|
|
197
|
+
paneId={leaf.id}
|
|
198
|
+
tabs={leaf.tabs}
|
|
199
|
+
active={leaf.active}
|
|
200
|
+
onActivate={(tabId) => { actions.activateTab(leaf.id, tabId) }}
|
|
201
|
+
onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
|
|
202
|
+
onNewTab={onNewTab}
|
|
203
|
+
newTabOptions={newTabOptions}
|
|
204
|
+
getTabIcon={getTabIcon}
|
|
205
|
+
getTabBadge={getTabBadge}
|
|
206
|
+
onDropTab={(payload, before) => {
|
|
207
|
+
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
|
|
208
|
+
else actions.moveTabBefore(payload, leaf.id, before)
|
|
209
|
+
}}
|
|
210
|
+
/>
|
|
211
|
+
{leaf.tabs.length > 0 ? (
|
|
212
|
+
/*
|
|
213
|
+
Every tab stays MOUNTED (inactive ones hidden), so switching tabs
|
|
214
|
+
never tears down the content: a terminal keeps its pty connection
|
|
215
|
+
and scrollback, an editor keeps its CodeMirror view and unsaved
|
|
216
|
+
draft, explorer/git keep their loaded data. The unmount (and the
|
|
217
|
+
terminal's close frame) happens only when a tab is truly closed.
|
|
218
|
+
*/
|
|
219
|
+
<div className={css.paneContent}>
|
|
220
|
+
{leaf.tabs.map(tab => (
|
|
221
|
+
<div
|
|
222
|
+
key={tab.id}
|
|
223
|
+
className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
|
|
224
|
+
>
|
|
225
|
+
{renderTab(tab, tab.id === activeTab?.id, leaf.id)}
|
|
226
|
+
</div>
|
|
227
|
+
))}
|
|
228
|
+
</div>
|
|
229
|
+
) : (
|
|
230
|
+
<PaneEmptyCards newTabOptions={newTabOptions} onNewTab={onNewTab} />
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Recursive node renderer. */
|
|
237
|
+
function NodeView(props: {
|
|
238
|
+
node: SplitNode
|
|
239
|
+
state: SidebarState
|
|
240
|
+
newTabOptions: NewTabOption[]
|
|
241
|
+
actions: WorkbenchActions
|
|
242
|
+
onNewTab: (optionId: string) => void
|
|
243
|
+
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
|
244
|
+
getTabIcon?: (tab: SidebarTab) => ReactNode
|
|
245
|
+
getTabBadge?: (tab: SidebarTab) => ReactNode
|
|
246
|
+
}) {
|
|
247
|
+
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
|
|
248
|
+
if (node.kind === 'leaf') {
|
|
249
|
+
return (
|
|
250
|
+
<LeafView
|
|
251
|
+
leaf={node}
|
|
252
|
+
newTabOptions={newTabOptions}
|
|
253
|
+
actions={actions}
|
|
254
|
+
onNewTab={onNewTab}
|
|
255
|
+
renderTab={renderTab}
|
|
256
|
+
getTabIcon={getTabIcon}
|
|
257
|
+
getTabBadge={getTabBadge}
|
|
258
|
+
/>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
const isRow = node.dir === 'row'
|
|
262
|
+
return (
|
|
263
|
+
<div className={clsx(css.split, isRow ? css.splitRow : css.splitCol)}>
|
|
264
|
+
{node.children.map((child, index) => (
|
|
265
|
+
<Fragment key={child.id}>
|
|
266
|
+
{index > 0 && (
|
|
267
|
+
<Divider
|
|
268
|
+
dir={node.dir}
|
|
269
|
+
onResize={(deltaFrac) => { actions.resizeSplit(node.id, index - 1, deltaFrac) }}
|
|
270
|
+
/>
|
|
271
|
+
)}
|
|
272
|
+
<div
|
|
273
|
+
className={css.splitChild}
|
|
274
|
+
style={{ flexGrow: node.sizes[index], flexBasis: 0, minWidth: 0, minHeight: 0 }}
|
|
275
|
+
>
|
|
276
|
+
<NodeView
|
|
277
|
+
node={child}
|
|
278
|
+
state={state}
|
|
279
|
+
newTabOptions={newTabOptions}
|
|
280
|
+
actions={actions}
|
|
281
|
+
onNewTab={onNewTab}
|
|
282
|
+
renderTab={renderTab}
|
|
283
|
+
getTabIcon={getTabIcon}
|
|
284
|
+
getTabBadge={getTabBadge}
|
|
285
|
+
/>
|
|
286
|
+
</div>
|
|
287
|
+
</Fragment>
|
|
288
|
+
))}
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The workbench: the split tree filling the sidebar body. `tree` selects
|
|
294
|
+
* which tree renders (the right panel's by default; the bottom panel passes
|
|
295
|
+
* `state.bottomSplits` — the actions route by pane id, so one action set
|
|
296
|
+
* serves both). */
|
|
297
|
+
export function Workbench(props: {
|
|
298
|
+
state: SidebarState
|
|
299
|
+
tree?: SplitNode
|
|
300
|
+
newTabOptions: NewTabOption[]
|
|
301
|
+
actions: WorkbenchActions
|
|
302
|
+
onNewTab: (optionId: string) => void
|
|
303
|
+
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
|
|
304
|
+
getTabIcon?: (tab: SidebarTab) => ReactNode
|
|
305
|
+
getTabBadge?: (tab: SidebarTab) => ReactNode
|
|
306
|
+
}) {
|
|
307
|
+
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
|
|
308
|
+
return (
|
|
309
|
+
<div className={css.workbench}>
|
|
310
|
+
<NodeView
|
|
311
|
+
node={tree ?? state.splits}
|
|
312
|
+
state={state}
|
|
313
|
+
newTabOptions={newTabOptions}
|
|
314
|
+
actions={actions}
|
|
315
|
+
onNewTab={onNewTab}
|
|
316
|
+
renderTab={renderTab}
|
|
317
|
+
getTabIcon={getTabIcon}
|
|
318
|
+
getTabBadge={getTabBadge}
|
|
319
|
+
/>
|
|
320
|
+
</div>
|
|
321
|
+
)
|
|
322
|
+
}
|