@jaychang1989/dsh-webchat 0.3.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.
Files changed (45) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +18 -0
  3. package/README.md +78 -0
  4. package/README.zh.md +83 -0
  5. package/cordis.patch.yml +22 -0
  6. package/lib/client.js +1725 -0
  7. package/lib/client.js.map +1 -0
  8. package/lib/index.js +3887 -0
  9. package/lib/types/client/api.d.ts +74 -0
  10. package/lib/types/client/controller.d.ts +19 -0
  11. package/lib/types/client/index.d.ts +30 -0
  12. package/lib/types/client/locales.d.ts +169 -0
  13. package/lib/types/client/mount.d.ts +23 -0
  14. package/lib/types/client/panel/Markdown.d.ts +15 -0
  15. package/lib/types/client/panel/WebChatPanel.d.ts +28 -0
  16. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  17. package/lib/types/client/sidebar-entry.d.ts +22 -0
  18. package/lib/types/engine/engine.d.ts +247 -0
  19. package/lib/types/engine/html-md.d.ts +23 -0
  20. package/lib/types/index.d.ts +63 -0
  21. package/lib/types/protocol.d.ts +107 -0
  22. package/lib/types/routes.d.ts +22 -0
  23. package/lib/types/store.d.ts +69 -0
  24. package/lib/types/tools.d.ts +27 -0
  25. package/lib/types/transfer.d.ts +138 -0
  26. package/package.json +96 -0
  27. package/src/client/api.ts +90 -0
  28. package/src/client/controller.ts +43 -0
  29. package/src/client/css-modules.d.ts +5 -0
  30. package/src/client/index.ts +75 -0
  31. package/src/client/locales.ts +172 -0
  32. package/src/client/mount.tsx +125 -0
  33. package/src/client/panel/Markdown.tsx +251 -0
  34. package/src/client/panel/WebChatPanel.tsx +760 -0
  35. package/src/client/panel/panel.module.css +936 -0
  36. package/src/client/sidebar-entry-core.ts +207 -0
  37. package/src/client/sidebar-entry.ts +49 -0
  38. package/src/engine/engine.ts +1385 -0
  39. package/src/engine/html-md.ts +126 -0
  40. package/src/index.ts +212 -0
  41. package/src/protocol.ts +116 -0
  42. package/src/routes.ts +307 -0
  43. package/src/store.ts +217 -0
  44. package/src/tools.ts +257 -0
  45. package/src/transfer.ts +602 -0
@@ -0,0 +1,760 @@
1
+ /**
2
+ * The web-chat panel: engine/login status, a conversation sidebar (the local
3
+ * transcript list + management), the chat transcript with markdown rendering,
4
+ * streaming updates (polled), the composer with deep-think / internet-search
5
+ * toggles, and the transfer-to-Harness handoff. All data flows through the
6
+ * /api/dsh-webchat routes; the panel is a plain React root in the center column.
7
+ *
8
+ * Layout mirrors chat.deepseek.com: a left conversation sidebar (new chat +
9
+ * history + per-item rename/delete + clear-all) beside the main chat column
10
+ * (messages → composer with 深度思考 / 智能搜索 toggles → handoff dock). The
11
+ * mono register stays reserved for machine things — timestamps, counts,
12
+ * status, and the handoff eyebrow.
13
+ */
14
+
15
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
16
+ import type { ISessions, IWorkspaces, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
17
+ import type { WebChatApi } from '../api.ts'
18
+ import type { TransferMode, WebChatState, WebChatTranscript } from '../../protocol.ts'
19
+ import type { WebChatKey } from '../locales.ts'
20
+ import { Markdown } from './Markdown.tsx'
21
+ import css from './panel.module.css'
22
+
23
+ /** Interpolate {placeholder}s in a localized template. */
24
+ function fmt(template: string, values: Record<string, string>): string {
25
+ return template.replace(/\{(\w+)\}/g, (_, key: string) => values[key] ?? `{${key}}`)
26
+ }
27
+
28
+ /** Compact inline icons (stroke = currentColor, 16px viewBox). */
29
+ const PENCIL_ICON = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11.3 2.2l2.5 2.5L5.5 13H3v-2.5L11.3 2.2z"/></svg>'
30
+ const TRASH_ICON = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4h11M6.5 4V2.5h3V4M4 4l.5 9.5h7L12 4"/></svg>'
31
+ const CHECK_ICON = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 8.5L6.5 12 13 4.5"/></svg>'
32
+ const CROSS_ICON = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>'
33
+
34
+ export interface WebChatPanelProps {
35
+ api: WebChatApi
36
+ /** Locale accessor (the plugin registers its dictionaries on ctx.locale). */
37
+ tt: (key: WebChatKey) => string
38
+ /** Client sessions service (ctx.sessions) for opening transferred sessions. */
39
+ sessions: ISessions
40
+ /** Client workspaces service (ctx.workspaces) for the transfer target picker. */
41
+ workspaces: IWorkspaces
42
+ /** Resolve the current workspace directory (cwd for new sessions / exports). */
43
+ currentCwd: () => string | undefined
44
+ }
45
+
46
+ /** Time between /state polls while nothing is streaming. */
47
+ const POLL_IDLE_MS = 1_500
48
+ /** Faster polling while a reply is streaming. */
49
+ const POLL_STREAM_MS = 600
50
+
51
+ export function WebChatPanel({ api, tt, sessions, workspaces, currentCwd }: WebChatPanelProps) {
52
+ const [state, setState] = useState<WebChatState | null>(null)
53
+ const [viewChatId, setViewChatId] = useState<string | undefined>(undefined)
54
+ const [draft, setDraft] = useState('')
55
+ const [attachOpen, setAttachOpen] = useState(false)
56
+ const [imagePaths, setImagePaths] = useState('')
57
+ const [toast, setToast] = useState<{ text: string; error?: boolean } | null>(null)
58
+ const [transferring, setTransferring] = useState(false)
59
+ const [exporting, setExporting] = useState(false)
60
+ const [transferMode, setTransferMode] = useState<TransferMode>('distill')
61
+ const [targetWorkspaceId, setTargetWorkspaceId] = useState<string | undefined>(undefined)
62
+ const [targetSessionId, setTargetSessionId] = useState<string | undefined>(undefined)
63
+ const [deepThink, setDeepThink] = useState(false)
64
+ const [search, setSearch] = useState(false)
65
+ const [renamingId, setRenamingId] = useState<string | undefined>(undefined)
66
+ const [renameDraft, setRenameDraft] = useState('')
67
+ const [deleteArmId, setDeleteArmId] = useState<string | undefined>(undefined)
68
+ const [clearArm, setClearArm] = useState(false)
69
+ const [recoverOpen, setRecoverOpen] = useState(false)
70
+ const [webMissing, setWebMissing] = useState<string[]>([])
71
+ const [recovering, setRecovering] = useState(false)
72
+ const listRef = useRef<HTMLDivElement | null>(null)
73
+ const toastTimer = useRef<number | undefined>(undefined)
74
+ const armTimer = useRef<number | undefined>(undefined)
75
+ const stateRef = useRef<WebChatState | null>(null)
76
+ stateRef.current = state
77
+ const pinnedRef = useRef(true) // user is following the latest message (not scrolled up)
78
+ const prevChatRef = useRef<string | undefined>(undefined)
79
+
80
+ const showToast = useCallback((text: string, error = false): void => {
81
+ setToast({ text, error })
82
+ window.clearTimeout(toastTimer.current)
83
+ toastTimer.current = window.setTimeout(() => setToast(null), 5_000)
84
+ }, [])
85
+
86
+ // Workspace list feed for the transfer-target picker (reactive snapshot).
87
+ const workspaceSnapshot = useSyncExternalStore(
88
+ useCallback((listener: () => void) => workspaces.list.subscribe(listener), [workspaces]),
89
+ () => workspaces.list.getSnapshot(),
90
+ )
91
+ const workspaceItems = workspaceSnapshot.items
92
+ const workspaceBaselinesReady = workspaceSnapshot.baselinesReady
93
+
94
+ // Sessions feed for the "continue into" picker, scoped to the active
95
+ // workspace: a task is continued inside the workspace the user is working in,
96
+ // so cross-workspace sessions are hidden.
97
+ const sessionSnapshot = useSyncExternalStore(
98
+ useCallback((listener: () => void) => sessions.list.subscribe(listener), [sessions]),
99
+ () => sessions.list.getSnapshot(),
100
+ )
101
+
102
+ // The workspace the "continue into" list is scoped to. The transfer-target
103
+ // workspace selector drives it (defaults to the current session's workspace,
104
+ // set by the effect below); "ungrouped" leaves it undefined.
105
+ const selectedWorkspace = targetWorkspaceId === undefined
106
+ ? undefined
107
+ : workspaceItems.find(ws => ws.workspaceId === targetWorkspaceId)
108
+ const groupedSessionIds = new Set(workspaceItems.flatMap(ws => ws.sessionIds))
109
+ const continuationTargets = sessionSnapshot.ids.flatMap(id => {
110
+ const session = sessionSnapshot.byId[id]
111
+ if (session === undefined || session.blank === true) return []
112
+ if (selectedWorkspace !== undefined) {
113
+ if (!selectedWorkspace.sessionIds.includes(id)) return []
114
+ } else if (groupedSessionIds.has(id)) {
115
+ return []
116
+ }
117
+ return [{ sessionId: session.id, title: session.displayTitle, running: session.running }]
118
+ })
119
+
120
+ // Pick a default target workspace once the list is ready: prefer the current
121
+ // session's workspace, then the most recently active workspace, else ungrouped.
122
+ // Keep the transfer-target workspace defaulted to the active workspace until
123
+ // the user explicitly picks one; re-syncs as the current session moves.
124
+ const workspaceOverridden = useRef(false)
125
+ useEffect(() => {
126
+ if (workspaceOverridden.current || !workspaceBaselinesReady) return
127
+ const currentSessionId = sessions.list.getSnapshot().current
128
+ const currentWorkspace = currentSessionId === undefined ? undefined : workspaceItems.find(ws => ws.sessionIds.includes(currentSessionId))
129
+ setTargetWorkspaceId(currentWorkspace?.workspaceId ?? workspaceSnapshot.recentWorkspaceId)
130
+ }, [workspaceBaselinesReady, workspaceItems, workspaceSnapshot.recentWorkspaceId, sessions])
131
+
132
+ const creatingWorkspace = useRef(false)
133
+ const createWorkspace = useCallback(async (): Promise<void> => {
134
+ if (creatingWorkspace.current) return
135
+ creatingWorkspace.current = true
136
+ try {
137
+ const path = await workspaces.pickDirectory()
138
+ if (path === null || path === '') return
139
+ const created = await workspaces.create({ path })
140
+ setTargetWorkspaceId(created.workspaceId)
141
+ showToast(fmt(tt('transfer.workspace.created'), { title: created.title }))
142
+ } catch (error) {
143
+ showToast(fmt(tt('transfer.workspace.createFailed'), { error: String(error) }), true)
144
+ } finally {
145
+ creatingWorkspace.current = false
146
+ }
147
+ }, [workspaces, showToast, tt])
148
+
149
+ // Poll /state.
150
+ useEffect(() => {
151
+ let cancelled = false
152
+ let timer: number | undefined
153
+ const poll = async (): Promise<void> => {
154
+ if (cancelled) return
155
+ try {
156
+ const snapshot = await api.state()
157
+ if (cancelled) return
158
+ setState(snapshot)
159
+ setViewChatId(previous => {
160
+ const active = snapshot.activeChatId
161
+ if (previous !== undefined && snapshot.chats.some(chat => chat.id === previous)) return previous
162
+ return active
163
+ })
164
+ } catch {
165
+ // transient poll failure: keep the previous snapshot
166
+ }
167
+ const streaming = stateRef.current?.chats.some(chat => chat.streaming) ?? false
168
+ timer = window.setTimeout(poll, streaming ? POLL_STREAM_MS : POLL_IDLE_MS)
169
+ }
170
+ void poll()
171
+ return () => {
172
+ cancelled = true
173
+ window.clearTimeout(timer)
174
+ }
175
+ }, [api])
176
+
177
+ // Reconcile the web page's toggle state whenever the server reports it.
178
+ useEffect(() => {
179
+ if (state === null) return
180
+ setDeepThink(state.deepThink)
181
+ setSearch(state.search)
182
+ }, [state?.deepThink, state?.search])
183
+
184
+ // Auto-scroll to the bottom only when the user is following the latest
185
+ // message (pinned) or has just switched chats — never yank the view while
186
+ // they are reading history.
187
+ useEffect(() => {
188
+ const list = listRef.current
189
+ if (list === null) return
190
+ const switched = prevChatRef.current !== viewChatId
191
+ prevChatRef.current = viewChatId
192
+ if (switched || pinnedRef.current) {
193
+ list.scrollTop = list.scrollHeight
194
+ }
195
+ }, [state, viewChatId])
196
+
197
+ // Track whether the user has scrolled away from the bottom.
198
+ const handleScroll = useCallback((): void => {
199
+ const list = listRef.current
200
+ if (list === null) return
201
+ const nearBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 96
202
+ pinnedRef.current = nearBottom
203
+ }, [])
204
+
205
+ const chats: WebChatTranscript[] = state?.chats ?? []
206
+ const viewChat = chats.find(chat => chat.id === viewChatId) ?? chats[0]
207
+ const busy = state?.busy ?? false
208
+ const loggedIn = state?.loggedIn ?? null
209
+ const streaming = viewChat?.streaming ?? false
210
+
211
+ const modelLabel = useCallback((model: string): string => {
212
+ return model === 'deepseek-reasoner' ? tt('model.deepseek-reasoner') : tt('model.deepseek-chat')
213
+ }, [tt])
214
+
215
+ const send = useCallback(async (): Promise<void> => {
216
+ const text = draft.trim()
217
+ const images = imagePaths.split(/[\n,;]+/).map(path => path.trim()).filter(path => path !== '')
218
+ if ((text === '' && images.length === 0) || busy) return
219
+ setDraft('')
220
+ setImagePaths('')
221
+ pinnedRef.current = true // follow the reply we just sent
222
+ try {
223
+ const result = await api.send(text, images.length > 0 ? images : undefined)
224
+ if (result.ok !== true) showToast(result.error ?? 'send failed', true)
225
+ else setViewChatId(result.chatId)
226
+ } catch (error) {
227
+ showToast(String(error), true)
228
+ }
229
+ }, [draft, imagePaths, busy, api, showToast])
230
+
231
+ const stop = useCallback(async (): Promise<void> => {
232
+ await api.stop().catch(() => undefined)
233
+ }, [api])
234
+
235
+ const newChat = useCallback(async (): Promise<void> => {
236
+ try {
237
+ const result = await api.newChat()
238
+ if (result.ok === true && result.chatId !== undefined) setViewChatId(result.chatId)
239
+ else showToast(result.error ?? 'new chat failed', true)
240
+ } catch (error) {
241
+ showToast(String(error), true)
242
+ }
243
+ }, [api, showToast])
244
+
245
+ const toggleDeepThink = useCallback(async (): Promise<void> => {
246
+ const next = !deepThink
247
+ setDeepThink(next)
248
+ try {
249
+ const result = await api.setDeepThink(next)
250
+ if (result.ok !== true) showToast(result.error ?? 'toggle deep think failed', true)
251
+ } catch (error) {
252
+ showToast(String(error), true)
253
+ }
254
+ }, [deepThink, api, showToast])
255
+
256
+ const toggleSearch = useCallback(async (): Promise<void> => {
257
+ const next = !search
258
+ setSearch(next)
259
+ try {
260
+ const result = await api.setSearch(next)
261
+ if (result.ok !== true) showToast(result.error ?? 'toggle search failed', true)
262
+ } catch (error) {
263
+ showToast(String(error), true)
264
+ }
265
+ }, [search, api, showToast])
266
+
267
+ const openLogin = useCallback(async (): Promise<void> => {
268
+ try {
269
+ const result = await api.openLogin()
270
+ if (result.ok !== true) showToast(result.error ?? 'open login failed', true)
271
+ } catch (error) {
272
+ showToast(String(error), true)
273
+ }
274
+ }, [api, showToast])
275
+
276
+ const closeBrowser = useCallback(async (): Promise<void> => {
277
+ await api.closeBrowser().catch(() => undefined)
278
+ }, [api])
279
+
280
+ const toggleRecover = useCallback(async (): Promise<void> => {
281
+ if (recoverOpen) {
282
+ setRecoverOpen(false)
283
+ return
284
+ }
285
+ try {
286
+ const result = await api.webChats()
287
+ setWebMissing(result.ok === true ? (result.missing ?? []) : [])
288
+ setRecoverOpen(true)
289
+ } catch (error) {
290
+ showToast(String(error), true)
291
+ }
292
+ }, [recoverOpen, api, showToast])
293
+
294
+ const recoverFromWeb = useCallback(async (title: string): Promise<void> => {
295
+ if (recovering) return
296
+ setRecovering(true)
297
+ try {
298
+ const result = await api.recover(title)
299
+ if (result.ok !== true || result.chatId === undefined) {
300
+ showToast(result.error ?? 'recover failed', true)
301
+ return
302
+ }
303
+ setWebMissing(list => list.filter(item => item !== title))
304
+ showToast(fmt(tt('recover.done'), { title: result.title ?? title }))
305
+ } catch (error) {
306
+ showToast(String(error), true)
307
+ } finally {
308
+ setRecovering(false)
309
+ }
310
+ }, [recovering, api, showToast, tt])
311
+
312
+ const transferToHarness = useCallback(async (): Promise<void> => {
313
+ if (viewChat === undefined || transferring) return
314
+ setTransferring(true)
315
+ try {
316
+ const result = await api.transfer(viewChat.id, currentCwd(), transferMode, targetWorkspaceId, targetSessionId)
317
+ if (result.ok !== true || result.sessionId === undefined) {
318
+ showToast(result.error ?? 'transfer failed', true)
319
+ return
320
+ }
321
+ const target = result.sessionId
322
+ if (result.continued === true) {
323
+ // Continuation: the session is already listed — just open it.
324
+ showToast(fmt(tt('transfer.continued'), { sessionId: target }))
325
+ sessions.open(target as SessionId)
326
+ return
327
+ }
328
+ if (result.attached === false && targetWorkspaceId !== undefined) {
329
+ showToast(fmt(tt('transfer.attached.failed'), { sessionId: target }), true)
330
+ } else {
331
+ showToast(fmt(tt('transfer.done'), { sessionId: target }))
332
+ }
333
+ // The session is persisted cold, so the cached list does not know about
334
+ // it yet. Trigger a full list refresh (concrete-only on the runtime
335
+ // sessions service), then poll until the row lands and open it.
336
+ const refreshable = sessions as ISessions & { refresh?: () => Promise<void> }
337
+ try {
338
+ await refreshable.refresh?.()
339
+ } catch {
340
+ // Refresh is best-effort; fall through to the poll below.
341
+ }
342
+ const tryOpen = (): void => {
343
+ const snapshot = sessions.list.getSnapshot()
344
+ if (snapshot.byId[target as SessionId] !== undefined) {
345
+ sessions.open(target as SessionId)
346
+ return
347
+ }
348
+ window.setTimeout(tryOpen, 300)
349
+ }
350
+ window.setTimeout(tryOpen, 200)
351
+ } catch (error) {
352
+ showToast(String(error), true)
353
+ } finally {
354
+ setTransferring(false)
355
+ }
356
+ }, [viewChat, transferring, api, currentCwd, sessions, showToast, tt, transferMode, targetWorkspaceId, targetSessionId])
357
+
358
+ const exportFile = useCallback(async (): Promise<void> => {
359
+ if (viewChat === undefined || exporting) return
360
+ setExporting(true)
361
+ try {
362
+ const result = await api.exportFile(viewChat.id, currentCwd())
363
+ if (result.ok !== true || result.filePath === undefined) {
364
+ showToast(result.error ?? 'export failed', true)
365
+ } else {
366
+ showToast(fmt(tt('export.done'), { filePath: result.filePath }))
367
+ }
368
+ } catch (error) {
369
+ showToast(String(error), true)
370
+ } finally {
371
+ setExporting(false)
372
+ }
373
+ }, [viewChat, exporting, api, currentCwd, showToast, tt])
374
+
375
+ // --- transcript management (rename / delete / clear-all) -------------------
376
+ const resetArmTimer = useCallback((next: (() => void) | undefined): void => {
377
+ window.clearTimeout(armTimer.current)
378
+ armTimer.current = next === undefined ? undefined : window.setTimeout(next, 3_000)
379
+ }, [])
380
+
381
+ const startRename = useCallback((chat: WebChatTranscript): void => {
382
+ setDeleteArmId(undefined)
383
+ setClearArm(false)
384
+ setRenameDraft(chat.title)
385
+ setRenamingId(chat.id)
386
+ }, [])
387
+
388
+ const commitRename = useCallback(async (): Promise<void> => {
389
+ const id = renamingId
390
+ if (id === undefined) return
391
+ const title = renameDraft.trim().replace(/\s+/g, ' ')
392
+ setRenamingId(undefined)
393
+ const original = chats.find(chat => chat.id === id)
394
+ if (title === '' || (original !== undefined && title === original.title)) return
395
+ try {
396
+ const result = await api.renameChat(id, title)
397
+ if (result.ok !== true) showToast(fmt(tt('toast.rename.failed'), { error: result.error ?? '' }), true)
398
+ else showToast(tt('toast.rename.done'))
399
+ } catch (error) {
400
+ showToast(fmt(tt('toast.rename.failed'), { error: String(error) }), true)
401
+ }
402
+ }, [renamingId, renameDraft, chats, api, showToast, tt])
403
+
404
+ const cancelRename = useCallback((): void => {
405
+ setRenamingId(undefined)
406
+ setRenameDraft('')
407
+ }, [])
408
+
409
+ const requestDelete = useCallback(async (id: string): Promise<void> => {
410
+ if (deleteArmId !== id) {
411
+ setClearArm(false)
412
+ setRenamingId(undefined)
413
+ setDeleteArmId(id)
414
+ resetArmTimer(() => setDeleteArmId(undefined))
415
+ return
416
+ }
417
+ resetArmTimer(undefined)
418
+ setDeleteArmId(undefined)
419
+ try {
420
+ const result = await api.deleteChat(id)
421
+ if (result.ok !== true) showToast(fmt(tt('toast.delete.failed'), { error: result.error ?? '' }), true)
422
+ else showToast(tt('toast.delete.done'))
423
+ } catch (error) {
424
+ showToast(fmt(tt('toast.delete.failed'), { error: String(error) }), true)
425
+ }
426
+ }, [deleteArmId, api, showToast, tt, resetArmTimer])
427
+
428
+ const requestClear = useCallback(async (): Promise<void> => {
429
+ if (chats.length === 0) return
430
+ if (!clearArm) {
431
+ setDeleteArmId(undefined)
432
+ setRenamingId(undefined)
433
+ setClearArm(true)
434
+ resetArmTimer(() => setClearArm(false))
435
+ return
436
+ }
437
+ resetArmTimer(undefined)
438
+ setClearArm(false)
439
+ try {
440
+ const result = await api.clearChats()
441
+ if (result.ok !== true) showToast(fmt(tt('toast.clear.failed'), { error: result.error ?? '' }), true)
442
+ else showToast(tt('toast.clear.done'))
443
+ } catch (error) {
444
+ showToast(fmt(tt('toast.clear.failed'), { error: String(error) }), true)
445
+ }
446
+ }, [chats.length, clearArm, api, showToast, tt, resetArmTimer])
447
+
448
+ const engineState = state?.engine ?? 'stopped'
449
+ const engineError = state?.engineError
450
+ const statusDot: 'ok' | 'warn' | 'bad' | 'busy' =
451
+ busy ? 'busy'
452
+ : engineState === 'error' ? 'bad'
453
+ : loggedIn === true ? 'ok'
454
+ : 'warn'
455
+ const statusText = busy
456
+ ? tt('composer.busy')
457
+ : engineState === 'error'
458
+ ? `${tt('status.engine.error')}${engineError !== undefined ? `: ${engineError}` : ''}`
459
+ : engineState === 'launching'
460
+ ? tt('status.engine.launching')
461
+ : loggedIn === true
462
+ ? tt('status.loggedIn')
463
+ : loggedIn === false
464
+ ? tt('status.notLoggedIn')
465
+ : tt('status.unknown')
466
+
467
+ const togglesDisabled = busy || loggedIn !== true
468
+
469
+ return (
470
+ <div className={css.panel}>
471
+ {/* Identity + live status */}
472
+ <header className={css.panelHeader}>
473
+ <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
474
+ <div className={css.headerSpacer} />
475
+ <div className={css.statusChip}>
476
+ <span className={css.statusDot} data-state={statusDot} />
477
+ <span className={css.statusText}>{statusText}</span>
478
+ </div>
479
+ {loggedIn !== true && (
480
+ <button className={`${css.button} ${css.buttonPrimary}`} onClick={() => { void openLogin() }}>
481
+ {tt('action.openLogin')}
482
+ </button>
483
+ )}
484
+ {engineState !== 'stopped' && (
485
+ <button className={css.buttonGhost} onClick={() => { void closeBrowser() }}>
486
+ {tt('action.closeBrowser')}
487
+ </button>
488
+ )}
489
+ </header>
490
+
491
+ <div className={css.panelBody}>
492
+ {/* Conversation sidebar (chat history + management) */}
493
+ <aside className={css.sidebar}>
494
+ <button className={`${css.button} ${css.buttonPrimary} ${css.sidebarNew}`} onClick={() => { void newChat() }}>
495
+ {tt('action.newChat')}
496
+ </button>
497
+ <button
498
+ className={`${css.buttonGhost} ${css.sidebarNew}`}
499
+ disabled={loggedIn !== true}
500
+ onClick={() => { void toggleRecover() }}
501
+ title={tt('recover.hint')}
502
+ >
503
+ {tt('action.recover')}
504
+ </button>
505
+ {recoverOpen && (
506
+ <div className={css.recoverList}>
507
+ {webMissing.length === 0 ? (
508
+ <div className={css.recoverEmpty}>{tt('recover.empty')}</div>
509
+ ) : (
510
+ webMissing.map(title => (
511
+ <button
512
+ className={css.recoverItem}
513
+ key={title}
514
+ disabled={recovering}
515
+ onClick={() => { void recoverFromWeb(title) }}
516
+ title={title}
517
+ >
518
+ <span className={css.recoverTitle}>{title}</span>
519
+ </button>
520
+ ))
521
+ )}
522
+ </div>
523
+ )}
524
+ <div className={css.sidebarList}>
525
+ {chats.length === 0 ? (
526
+ <div className={css.sidebarEmpty}>{tt('sidebar.empty')}</div>
527
+ ) : (
528
+ chats.map(chat => (
529
+ renamingId === chat.id ? (
530
+ <div className={css.sidebarItem} data-active={chat.id === viewChatId ? 'true' : undefined} key={chat.id}>
531
+ <div className={css.sidebarRename}>
532
+ <input
533
+ className={css.sidebarRenameInput}
534
+ value={renameDraft}
535
+ placeholder={tt('chat.rename.placeholder')}
536
+ aria-label="rename"
537
+ autoFocus
538
+ onChange={event => setRenameDraft(event.target.value)}
539
+ onKeyDown={event => {
540
+ if (event.key === 'Enter') {
541
+ event.preventDefault()
542
+ void commitRename()
543
+ } else if (event.key === 'Escape') {
544
+ cancelRename()
545
+ }
546
+ }}
547
+ />
548
+ <button className={css.sidebarAction} onClick={() => { void commitRename() }} aria-label={tt('chat.rename.ok')} dangerouslySetInnerHTML={{ __html: CHECK_ICON }} />
549
+ <button className={css.sidebarAction} onClick={cancelRename} aria-label={tt('chat.rename.cancel')} dangerouslySetInnerHTML={{ __html: CROSS_ICON }} />
550
+ </div>
551
+ </div>
552
+ ) : (
553
+ <div className={css.sidebarItem} data-active={chat.id === viewChatId ? 'true' : undefined} key={chat.id}>
554
+ <button className={css.sidebarRow} onClick={() => setViewChatId(chat.id)} title={chat.title}>
555
+ <span className={css.sidebarTitle}>{chat.title}</span>
556
+ <span className={css.sidebarMeta}>{fmt(tt('chats.count'), { count: String(chat.messages.length) })}</span>
557
+ </button>
558
+ <span className={css.sidebarActions}>
559
+ <button className={css.sidebarAction} onClick={() => startRename(chat)} aria-label={tt('chat.rename')} title={tt('chat.rename')} dangerouslySetInnerHTML={{ __html: PENCIL_ICON }} />
560
+ <button
561
+ className={deleteArmId === chat.id ? css.sidebarActionDanger : css.sidebarAction}
562
+ onClick={() => { void requestDelete(chat.id) }}
563
+ aria-label={deleteArmId === chat.id ? tt('chat.delete.confirm') : tt('chat.delete')}
564
+ title={deleteArmId === chat.id ? tt('chat.delete.confirm') : tt('chat.delete')}
565
+ >
566
+ {deleteArmId === chat.id ? tt('chat.delete.confirm') : <span dangerouslySetInnerHTML={{ __html: TRASH_ICON }} />}
567
+ </button>
568
+ </span>
569
+ </div>
570
+ )
571
+ ))
572
+ )}
573
+ </div>
574
+ <button
575
+ className={clearArm ? `${css.button} ${css.buttonDanger} ${css.sidebarClear}` : `${css.buttonGhost} ${css.sidebarClear}`}
576
+ disabled={chats.length === 0}
577
+ onClick={() => { void requestClear() }}
578
+ >
579
+ {clearArm ? tt('chat.clearAll.confirm') : tt('chat.clearAll')}
580
+ </button>
581
+ </aside>
582
+
583
+ {/* Main chat column */}
584
+ <div className={css.main}>
585
+ <div className={css.messageList} ref={listRef} onScroll={handleScroll}>
586
+ {viewChat === undefined || viewChat.messages.length === 0 ? (
587
+ <div className={css.emptyState}>
588
+ <div className={css.emptyEyebrow}>{tt('empty.eyebrow')}</div>
589
+ <h3 className={css.emptyTitle}>{tt('empty.title')}</h3>
590
+ <p className={css.emptyBody}>{tt('empty.body')}</p>
591
+ {loggedIn !== true && (
592
+ <button className={`${css.button} ${css.buttonPrimary}`} onClick={() => { void openLogin() }}>
593
+ {tt('action.openLogin')}
594
+ </button>
595
+ )}
596
+ </div>
597
+ ) : (
598
+ viewChat.messages.map(message => (
599
+ <div className={css.messageRow} data-role={message.role} key={message.id}>
600
+ <div className={css.messageMeta}>
601
+ <span className={css.messageSpeaker}>{message.role === 'user' ? tt('role.you') : modelLabel(viewChat.model)}</span>
602
+ <span className={css.messageTime}>{new Date(message.ts).toLocaleTimeString()}</span>
603
+ {message.streaming === true && <span className={css.streamBadge}>{tt('streaming')}…</span>}
604
+ </div>
605
+ <div className={css.messageBubble}>
606
+ <Markdown source={message.content} />
607
+ {message.error !== undefined && message.content !== '' && (
608
+ <div className={css.messageError}>{fmt(tt('msg.error'), { error: message.error })}</div>
609
+ )}
610
+ </div>
611
+ </div>
612
+ ))
613
+ )}
614
+ </div>
615
+
616
+ <div className={css.composer}>
617
+ <div className={css.composerToolbar}>
618
+ <button
619
+ className={css.toggleButton}
620
+ data-active={deepThink ? 'true' : undefined}
621
+ disabled={togglesDisabled}
622
+ title={tt('toggle.deepThink.hint')}
623
+ aria-pressed={deepThink}
624
+ onClick={() => { void toggleDeepThink() }}
625
+ >
626
+ {tt('toggle.deepThink')}
627
+ </button>
628
+ <button
629
+ className={css.toggleButton}
630
+ data-active={search ? 'true' : undefined}
631
+ disabled={togglesDisabled}
632
+ title={tt('toggle.search.hint')}
633
+ aria-pressed={search}
634
+ onClick={() => { void toggleSearch() }}
635
+ >
636
+ {tt('toggle.search')}
637
+ </button>
638
+ </div>
639
+ <textarea
640
+ className={css.composerInput}
641
+ value={draft}
642
+ placeholder={busy ? tt('composer.busy') : loggedIn !== true ? tt('composer.notLoggedIn') : tt('composer.placeholder')}
643
+ disabled={busy || loggedIn !== true}
644
+ onChange={event => setDraft(event.target.value)}
645
+ onKeyDown={event => {
646
+ if (event.key === 'Enter' && !event.shiftKey) {
647
+ event.preventDefault()
648
+ void send()
649
+ }
650
+ }}
651
+ />
652
+ {attachOpen && (
653
+ <input
654
+ className={css.attachInput}
655
+ value={imagePaths}
656
+ placeholder={tt('composer.attach.placeholder')}
657
+ disabled={busy || loggedIn !== true}
658
+ onChange={event => setImagePaths(event.target.value)}
659
+ />
660
+ )}
661
+ <div className={css.composerFooter}>
662
+ <button
663
+ className={css.toggleButton}
664
+ data-active={attachOpen ? 'true' : undefined}
665
+ title={tt('composer.attach.hint')}
666
+ aria-pressed={attachOpen}
667
+ disabled={busy || loggedIn !== true}
668
+ onClick={() => setAttachOpen(open => !open)}
669
+ >
670
+ {tt('composer.attach')}
671
+ </button>
672
+ <span className={css.composerHint}>{tt('composer.hint')}</span>
673
+ <div className={css.composerSpacer} />
674
+ {streaming ? (
675
+ <button className={`${css.button} ${css.buttonDanger}`} onClick={() => { void stop() }}>
676
+ {tt('action.stop')}
677
+ </button>
678
+ ) : (
679
+ <button
680
+ className={`${css.button} ${css.buttonPrimary}`}
681
+ disabled={busy || loggedIn !== true || (draft.trim() === '' && imagePaths.trim() === '')}
682
+ onClick={() => { void send() }}
683
+ >
684
+ {tt('action.send')}
685
+ </button>
686
+ )}
687
+ </div>
688
+ </div>
689
+
690
+ {/* Handoff dock — the chat → harness gateway (signature) */}
691
+ <div className={css.handoffDock}>
692
+ <span className={css.handoffEyebrow}>{tt('handoff.eyebrow')}</span>
693
+ <div className={css.handoffSpacer} />
694
+ <div className={css.segmented} role="group" aria-label="transfer mode" title={tt('transfer.mode.hint')}>
695
+ <button
696
+ className={css.segmentedButton}
697
+ data-active={transferMode === 'distill' ? 'true' : undefined}
698
+ onClick={() => setTransferMode('distill')}
699
+ >
700
+ {tt('transfer.mode.distill')}
701
+ </button>
702
+ <button
703
+ className={css.segmentedButton}
704
+ data-active={transferMode === 'raw' ? 'true' : undefined}
705
+ onClick={() => setTransferMode('raw')}
706
+ >
707
+ {tt('transfer.mode.raw')}
708
+ </button>
709
+ </div>
710
+ <select
711
+ className={css.workspaceSelect}
712
+ value={targetSessionId ?? ''}
713
+ disabled={continuationTargets.length === 0}
714
+ title={tt('transfer.session.hint')}
715
+ aria-label={tt('transfer.session.label')}
716
+ onChange={event => setTargetSessionId(event.target.value === '' ? undefined : event.target.value)}
717
+ >
718
+ <option value="">{tt('transfer.session.new')}</option>
719
+ {continuationTargets.map(session => (
720
+ <option key={session.sessionId} value={session.sessionId}>
721
+ {session.title}{session.running === true ? ' ·●' : ''}
722
+ </option>
723
+ ))}
724
+ </select>
725
+ <select
726
+ className={css.workspaceSelect}
727
+ value={targetWorkspaceId ?? ''}
728
+ disabled={targetSessionId !== undefined}
729
+ title={tt('transfer.workspace.hint')}
730
+ aria-label={tt('transfer.workspace.label')}
731
+ onChange={event => { workspaceOverridden.current = true; setTargetWorkspaceId(event.target.value === '' ? undefined : event.target.value) }}
732
+ >
733
+ <option value="">{tt('transfer.workspace.ungrouped')}</option>
734
+ {workspaceItems.map(ws => (
735
+ <option key={ws.workspaceId} value={ws.workspaceId}>
736
+ {ws.title}{ws.path !== ws.title ? ` — ${ws.path}` : ''}
737
+ </option>
738
+ ))}
739
+ </select>
740
+ <button className={css.buttonGhost} onClick={() => { void createWorkspace() }} title={tt('transfer.workspace.new')} disabled={targetSessionId !== undefined}>
741
+ {tt('transfer.workspace.new')}
742
+ </button>
743
+ <button
744
+ className={`${css.button} ${css.buttonPrimary}`}
745
+ disabled={viewChat === undefined || transferring}
746
+ onClick={() => { void transferToHarness() }}
747
+ >
748
+ {tt('action.transferToHarness')}
749
+ </button>
750
+ <button className={css.button} disabled={viewChat === undefined || exporting} onClick={() => { void exportFile() }}>
751
+ {tt('action.exportFile')}
752
+ </button>
753
+ </div>
754
+ </div>
755
+ </div>
756
+
757
+ {toast !== null && <div className={css.toast} data-error={toast.error === true ? 'true' : undefined}>{toast.text}</div>}
758
+ </div>
759
+ )
760
+ }