dsh-coding-sidebar 1.0.7 → 1.0.8

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 (69) hide show
  1. package/README.md +2 -2
  2. package/lib/client-editor.js +436 -227
  3. package/lib/client-registry.js +627 -7391
  4. package/lib/client-terminal.js +234 -182
  5. package/lib/client.js +634 -7398
  6. package/lib/index.js +367 -67
  7. package/lib/types/agent-pty.d.ts +62 -4
  8. package/lib/types/bundle-route.d.ts +1 -1
  9. package/lib/types/client/EditorHost.d.ts +1 -1
  10. package/lib/types/client/FileTree.d.ts +2 -2
  11. package/lib/types/client/TreePanel.d.ts +1 -1
  12. package/lib/types/client/chunk-loader.d.ts +1 -1
  13. package/lib/types/client/chunks/locale.d.ts +3 -0
  14. package/lib/types/client/conversation-draft.d.ts +85 -4
  15. package/lib/types/client/locales.d.ts +1 -20
  16. package/lib/types/client/selection-popup.d.ts +58 -0
  17. package/lib/types/client/service.d.ts +1 -1
  18. package/lib/types/client/terminal-font.d.ts +25 -2
  19. package/lib/types/context-types.d.ts +3 -1
  20. package/lib/types/index.d.ts +9 -0
  21. package/lib/types/prefs-shared.d.ts +7 -5
  22. package/lib/types/pty-manager.d.ts +53 -0
  23. package/lib/types/wire.d.ts +6 -2
  24. package/package.json +1 -1
  25. package/src/agent-pty.ts +221 -33
  26. package/src/bundle-route.ts +1 -1
  27. package/src/client/EditorHost.tsx +1 -1
  28. package/src/client/FileTree.tsx +4 -4
  29. package/src/client/Sidebar.tsx +41 -22
  30. package/src/client/TerminalView.tsx +13 -0
  31. package/src/client/TextEditor.tsx +27 -45
  32. package/src/client/TreePanel.tsx +16 -1
  33. package/src/client/chunk-loader.ts +1 -1
  34. package/src/client/chunks/locale.tsx +60 -0
  35. package/src/client/conversation-draft.ts +233 -7
  36. package/src/client/index.tsx +19 -8
  37. package/src/client/locales-ar.ts +5 -4
  38. package/src/client/locales-de.ts +5 -4
  39. package/src/client/locales-fr.ts +5 -4
  40. package/src/client/locales-hi.ts +5 -4
  41. package/src/client/locales-id.ts +5 -4
  42. package/src/client/locales-it.ts +5 -4
  43. package/src/client/locales-ja.ts +5 -4
  44. package/src/client/locales-ko.ts +5 -4
  45. package/src/client/locales-nl.ts +5 -4
  46. package/src/client/locales-pl.ts +5 -4
  47. package/src/client/locales-pt.ts +5 -4
  48. package/src/client/locales-ru.ts +5 -4
  49. package/src/client/locales-sv.ts +5 -4
  50. package/src/client/locales-th.ts +5 -4
  51. package/src/client/locales-tr.ts +5 -4
  52. package/src/client/locales-vi.ts +5 -4
  53. package/src/client/locales-zh-HK.ts +5 -4
  54. package/src/client/locales-zh-MO.ts +5 -4
  55. package/src/client/locales-zh-TW.ts +5 -4
  56. package/src/client/locales.ts +17 -51
  57. package/src/client/selection-popup.ts +155 -0
  58. package/src/client/service.ts +1 -1
  59. package/src/client/state.ts +14 -7
  60. package/src/client/terminal-font.ts +34 -3
  61. package/src/context-types.ts +3 -2
  62. package/src/git.ts +17 -1
  63. package/src/index.ts +39 -6
  64. package/src/open-external.ts +3 -4
  65. package/src/prefs-shared.ts +7 -5
  66. package/src/pty-manager.ts +176 -5
  67. package/src/sidechat-routes.ts +13 -1
  68. package/src/tools.ts +24 -6
  69. package/src/wire.ts +3 -0
package/src/agent-pty.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { randomUUID } from 'node:crypto'
15
15
  import type { IPty } from 'node-pty'
16
- import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
16
+ import { ensureSpawnHelper, resolveShellExecutable, shellSpawnArgs } from './pty-manager.ts'
17
17
  import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
18
18
  import { SidebarError } from './wire.ts'
19
19
 
@@ -40,6 +40,79 @@ export function clampDims(cols: number, rows: number): { cols: number; rows: num
40
40
  return { cols: clamp(cols), rows: clamp(rows) }
41
41
  }
42
42
 
43
+ /**
44
+ * Per-pty Windows resize-gate state (see {@link armPtyResizeGate}).
45
+ */
46
+ interface ResizeGateState {
47
+ /** Whether the pty has produced any output yet (ConPTY flush completed). */
48
+ sawData: boolean
49
+ /** Latest dims requested before the first output; replayed once it arrives. */
50
+ pending?: { cols: number; rows: number }
51
+ }
52
+
53
+ /**
54
+ * node-pty's Windows terminal queues resize calls that arrive before the
55
+ * ConPTY control socket's first data flush (`_deferNoArgs` in
56
+ * windowsTerminal.js). If the pty exits before the queue flushes, the
57
+ * deferred resize throws inside the socket's 'data' handler — uncatchable
58
+ * by any caller and fatal to the host process. POSIX terminals have no such
59
+ * queue (resize is synchronous), so the gate is armed on Windows only:
60
+ * {@link tryResizePty} parks dims requested before the first output and
61
+ * replays them after the flush, when node-pty executes resizes
62
+ * synchronously (and the throw for an exited pty is catchable).
63
+ */
64
+ const ptyResizeGates = new WeakMap<object, ResizeGateState>()
65
+
66
+ /**
67
+ * Arm the Windows pre-ready resize gate for one freshly spawned pty.
68
+ * No-op on POSIX and for injected ptys without `onData`.
69
+ */
70
+ export function armPtyResizeGate(pty: IPty): void {
71
+ if (process.platform !== 'win32') return
72
+ if (typeof pty.onData !== 'function' || ptyResizeGates.has(pty)) return
73
+ const state: ResizeGateState = { sawData: false }
74
+ ptyResizeGates.set(pty, state)
75
+ pty.onData(() => {
76
+ if (state.sawData) return
77
+ state.sawData = true
78
+ const dims = state.pending
79
+ state.pending = undefined
80
+ if (dims === undefined) return
81
+ // node-pty's deferred flush shares this very 'data' dispatch; hop past
82
+ // it so the replayed resize executes synchronously (catchable) instead
83
+ // of being queued into the same deferred list we are draining.
84
+ setImmediate(() => { tryResizePty(pty, dims.cols, dims.rows) })
85
+ })
86
+ }
87
+
88
+ /**
89
+ * Best-effort resize for WebSocket-driven terminal views. Layout animation
90
+ * can briefly produce unusable dimensions, and node-pty can reject a resize
91
+ * after the socket setup's outer try/catch has returned. Ignore that one
92
+ * frame so the host stays alive and a later valid measurement can retry.
93
+ * Returns whether node-pty accepted the resize (or parked it for replay on
94
+ * the first output — the Windows pre-ready window).
95
+ */
96
+ export function tryResizePty(
97
+ pty: Pick<IPty, 'resize'>,
98
+ cols: number,
99
+ rows: number,
100
+ ): boolean {
101
+ if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false
102
+ const dims = clampDims(cols, rows)
103
+ const gate = ptyResizeGates.get(pty)
104
+ if (gate !== undefined && !gate.sawData) {
105
+ gate.pending = dims
106
+ return true
107
+ }
108
+ try {
109
+ pty.resize(dims.cols, dims.rows)
110
+ return true
111
+ } catch {
112
+ return false
113
+ }
114
+ }
115
+
43
116
  /**
44
117
  * Serializable snapshot of one agent terminal — the shape the model sees
45
118
  * through `terminal_list` and the sidebar sees through the push endpoint.
@@ -61,6 +134,13 @@ export interface AgentTerminalSnapshot {
61
134
  exitCode?: number | null
62
135
  /** Exit signal name if the process was killed by a signal; null otherwise. */
63
136
  exitSignal?: string | null
137
+
138
+ /**
139
+ * The model's active `terminal_wait_for` on this terminal (the sidebar
140
+ * renders the wait banner from it). Present only while a wait is
141
+ * registered; carries the LATEST wait when several overlap.
142
+ */
143
+ waiting?: { needle: string; since: number }
64
144
  }
65
145
 
66
146
  /** Map a POSIX signal number to its conventional name (best-effort). */
@@ -76,22 +156,56 @@ function signalNameOf(signal: number | null | undefined): string | null {
76
156
  return SIGNAL_NAMES[signal] ?? `signal ${signal}`
77
157
  }
78
158
 
79
- /** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
80
- function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
159
+ /**
160
+ * Compile a wait needle into a RegExp. The needle is treated as a JavaScript
161
+ * regular expression; a pattern that fails to compile (e.g. an unbalanced
162
+ * group typed as a literal) degrades to verbatim substring matching so
163
+ * legacy literal needles keep working.
164
+ */
165
+ function compileNeedle(needle: string): RegExp | null {
166
+ try {
167
+ return new RegExp(needle)
168
+ } catch {
169
+ return null
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Locate the first occurrence of `needle` in `transcript`, returning its
175
+ * line/column plus the actual matched text — for alternation patterns
176
+ * (e.g. `(BUILD_OK|BUILD_FAIL)`) the match tells which alternative hit.
177
+ * `re` is the precompiled form of `needle` (from {@link compileNeedle});
178
+ * `null` means verbatim substring matching.
179
+ */
180
+ function locateNeedle(
181
+ transcript: string,
182
+ needle: string,
183
+ re: RegExp | null,
184
+ ): { line: number; column: number; match: string } | undefined {
81
185
  if (needle === '') return undefined
82
- const idx = transcript.indexOf(needle)
83
- if (idx === -1) return undefined
186
+ // Regex hit → use the match index/text; literal fallback → indexOf with
187
+ // the needle itself as the matched text.
188
+ let hit: { index: number; text: string } | undefined
189
+ if (re !== null) {
190
+ re.lastIndex = 0
191
+ const m = re.exec(transcript)
192
+ hit = m === null ? undefined : { index: m.index, text: m[0] }
193
+ } else {
194
+ const idx = transcript.indexOf(needle)
195
+ hit = idx === -1 ? undefined : { index: idx, text: needle }
196
+ }
197
+ if (hit === undefined) return undefined
84
198
  // Walk the transcript up to the match index, counting newlines to derive
85
199
  // the 0-based line; the column is the offset within that line.
86
200
  let line = 0
87
201
  let lineStart = 0
88
- for (let i = 0; i < idx; i += 1) {
202
+ for (let i = 0; i < hit.index; i += 1) {
89
203
  if (transcript.charCodeAt(i) === 0x0a /* \n */) {
90
204
  line += 1
91
205
  lineStart = i + 1
92
206
  }
93
207
  }
94
- return { line, column: idx - lineStart }
208
+ return { line, column: hit.index - lineStart, match: hit.text }
95
209
  }
96
210
 
97
211
  /** One live agent terminal. */
@@ -116,6 +230,18 @@ export interface AgentTerminalHandle {
116
230
  exitCode?: number | null
117
231
  /** Exit signal number once known (POSIX only; undefined on Windows). */
118
232
  exitSignal?: number | null
233
+ /** Active wait_for registrations (skip bookkeeping; empty while idle). */
234
+ waits: AgentTerminalActiveWait[]
235
+ }
236
+
237
+ /** One active wait_for registration on a handle (banner + skip bookkeeping). */
238
+ export interface AgentTerminalActiveWait {
239
+ /** The needle being awaited (shown on the sidebar wait banner). */
240
+ needle: string
241
+ /** Epoch ms when the wait registered (age display / debugging). */
242
+ since: number
243
+ /** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */
244
+ skipped: boolean
119
245
  }
120
246
 
121
247
  /** Read result shape (mirrors the official tool-pty terminal_read contract). */
@@ -133,14 +259,18 @@ export interface AgentTerminalReadResult {
133
259
  /** Outcome of {@link AgentPtyRegistry.waitFor}. */
134
260
  export type AgentTerminalWaitResult =
135
261
  | {
136
- /** The needle was found in the transcript. */
262
+ /** The pattern that was awaited. */
137
263
  kind: 'found'
138
- /** The matched substring. */
139
264
  needle: string
140
265
  /** 0-based line index (in the retained transcript) where the needle first appeared. */
141
266
  line: number
142
267
  /** 0-based column index within that line where the match starts. */
143
268
  column: number
269
+ /**
270
+ * The text that actually matched — for multi-outcome patterns
271
+ * (e.g. `(BUILD_OK|BUILD_FAIL)`) this tells which alternative matched.
272
+ */
273
+ match: string
144
274
  /** Elapsed wall-clock milliseconds from the wait start to the match. */
145
275
  elapsedMs: number
146
276
  }
@@ -164,6 +294,12 @@ export type AgentTerminalWaitResult =
164
294
  /** The exit signal name, if the process was killed by a signal. */
165
295
  exitSignal?: string | null
166
296
  }
297
+ | {
298
+ /** The user skipped the wait from the sidebar banner. */
299
+ kind: 'skipped'
300
+ /** The needle that was awaited. */
301
+ needle: string
302
+ }
167
303
 
168
304
  /** Snapshot projection of a handle (drops the pty reference and transcript). */
169
305
  export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
@@ -177,6 +313,8 @@ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
177
313
  out.exitCode = handle.exitCode ?? null
178
314
  out.exitSignal = signalNameOf(handle.exitSignal)
179
315
  }
316
+ const active = handle.waits.at(-1)
317
+ if (active !== undefined) out.waiting = { needle: active.needle, since: active.since }
180
318
  return out
181
319
  }
182
320
 
@@ -219,13 +357,15 @@ export class AgentPtyRegistry {
219
357
  ): string {
220
358
  const uuid = randomUUID()
221
359
  const dims = clampDims(cols, rows)
222
- const pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
360
+ const executable = resolveShellExecutable(shell ?? this.shell)
361
+ const pty = this.nodePty.spawn(executable, shellSpawnArgs(shellArgs ?? this.shellArgs), {
223
362
  name: 'xterm-256color',
224
363
  cols: dims.cols,
225
364
  rows: dims.rows,
226
365
  cwd,
227
366
  env: { ...process.env },
228
367
  })
368
+ armPtyResizeGate(pty)
229
369
  const handle: AgentTerminalHandle = {
230
370
  uuid,
231
371
  sessionId,
@@ -235,6 +375,7 @@ export class AgentPtyRegistry {
235
375
  pty,
236
376
  transcript: '',
237
377
  exited: false,
378
+ waits: [],
238
379
  }
239
380
  pty.onData((data) => {
240
381
  handle.transcript += data
@@ -353,7 +494,11 @@ export class AgentPtyRegistry {
353
494
  resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
354
495
  const handle = this.expect(uuid)
355
496
  const dims = clampDims(cols, rows)
356
- if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
497
+ // Through tryResizePty: node-pty throws on a just-exited pty (the exit
498
+ // event may not have flipped `handle.exited` yet), and on Windows a
499
+ // pre-ready resize must go through the gate instead of node-pty's own
500
+ // deferred queue (which flushes uncatchably inside its socket handler).
501
+ if (!handle.exited) tryResizePty(handle.pty, dims.cols, dims.rows)
357
502
  return dims
358
503
  }
359
504
 
@@ -377,10 +522,17 @@ export class AgentPtyRegistry {
377
522
  * make event-driven wakeups unreliable. A 50ms poll is fast enough for
378
523
  * interactive use and simple enough to be obviously correct.
379
524
  * @param uuid - terminal to watch.
380
- * @param needle - substring to search for (case-sensitive, verbatim).
525
+ * @param needle - JavaScript regular expression to search for
526
+ * (case-sensitive); a pattern that fails to compile falls back to
527
+ * verbatim substring matching. May cover several outcomes at once
528
+ * (e.g. `(BUILD_OK|BUILD_FAIL)` for build success vs failure) — the
529
+ * returned `match` reports the text that actually matched, so callers
530
+ * can tell which outcome hit.
381
531
  * @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
382
532
  * @param signal - caller-owned cancellation; aborts the wait re-throwing.
383
- * @returns one of `found` / `timeout` / `exited`.
533
+ * A wait can also be skipped by the user from the sidebar banner
534
+ * (`skipWait`), which resolves it with `{kind:'skipped'}`.
535
+ * @returns one of `found` / `timeout` / `exited` / `skipped`.
384
536
  */
385
537
  async waitFor(
386
538
  uuid: string,
@@ -395,37 +547,73 @@ export class AgentPtyRegistry {
395
547
  const timeout = Math.max(100, Math.floor(timeoutMs))
396
548
  const start = Date.now()
397
549
  const deadline = start + timeout
550
+ // Compile the needle once (regex semantics; invalid patterns degrade to
551
+ // verbatim matching) and reuse the compiled form across every poll.
552
+ const re = compileNeedle(needle)
398
553
  // Fast path: already exited, or the needle is already in the transcript
399
554
  // (a `terminal_send` may have produced the expected output before this
400
555
  // call even started).
401
556
  if (handle.exited) {
402
557
  return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
403
558
  }
404
- const firstHit = locateNeedle(handle.transcript, needle)
559
+ const firstHit = locateNeedle(handle.transcript, needle, re)
405
560
  if (firstHit !== undefined) {
406
- return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
561
+ return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start }
407
562
  }
408
- // Poll loop: check the transcript every 50ms, exit on match / exit /
409
- // abort / timeout. The handle is read live each iteration (its transcript
410
- // and exited fields mutate as the pty produces output).
411
- while (true) {
412
- if (signal?.aborted) signal.throwIfAborted()
413
- if (handle.exited) {
414
- return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
415
- }
416
- const hit = locateNeedle(handle.transcript, needle)
417
- if (hit !== undefined) {
418
- return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
563
+ // Register the active wait so the sidebar can show the wait banner (the
564
+ // snapshot's `waiting` field rides the agent-terminals push). Registered
565
+ // only after the fast paths: a wait that resolves instantly never
566
+ // flashes the banner. The notify() makes every push subscriber (the
567
+ // sidebar view) converge on the new waiting state immediately.
568
+ const record: AgentTerminalActiveWait = { needle, since: start, skipped: false }
569
+ handle.waits.push(record)
570
+ this.notify()
571
+ try {
572
+ while (true) {
573
+ if (signal?.aborted) signal.throwIfAborted()
574
+ if (handle.exited) {
575
+ return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
576
+ }
577
+ if (record.skipped) {
578
+ return { kind: 'skipped', needle }
579
+ }
580
+ const hit = locateNeedle(handle.transcript, needle, re)
581
+ if (hit !== undefined) {
582
+ return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start }
583
+ }
584
+ if (Date.now() >= deadline) {
585
+ return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
586
+ }
587
+ await new Promise(resolve => {
588
+ const t = setTimeout(resolve, 50)
589
+ // Allow the Node process to exit even if the timer is pending.
590
+ if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
591
+ })
419
592
  }
420
- if (Date.now() >= deadline) {
421
- return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
593
+ } finally {
594
+ const index = handle.waits.indexOf(record)
595
+ if (index !== -1) handle.waits.splice(index, 1)
596
+ this.notify()
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Mark every active wait on one terminal as skipped (the sidebar banner's
602
+ * skip button). Each waiting poll loop observes its record's flag within
603
+ * one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing
604
+ * is waiting (a stale banner racing a wait that already resolved).
605
+ * @returns the number of waits that transitioned to skipped.
606
+ */
607
+ skipWait(uuid: string): number {
608
+ const handle = this.expect(uuid)
609
+ let count = 0
610
+ for (const record of handle.waits) {
611
+ if (!record.skipped) {
612
+ record.skipped = true
613
+ count += 1
422
614
  }
423
- await new Promise(resolve => {
424
- const t = setTimeout(resolve, 50)
425
- // Allow the Node process to exit even if the timer is pending.
426
- if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
427
- })
428
615
  }
616
+ return count
429
617
  }
430
618
 
431
619
  /**
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
19
19
  import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
20
20
 
21
21
  /** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
22
- export const CHUNK_NAMES = ['terminal', 'editor'] as const
22
+ export const CHUNK_NAMES = ['terminal', 'editor', 'locale'] as const
23
23
  export type ChunkName = (typeof CHUNK_NAMES)[number]
24
24
 
25
25
  /** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
@@ -107,7 +107,7 @@ export function EditorHost(props: {
107
107
  expanded: string[]
108
108
  revealed: string[]
109
109
  onToggleDir: (path: string) => void
110
- onReferenceFile: (path: string) => void
110
+ onReferenceFile: (path: string, isDir: boolean) => void
111
111
  }) {
112
112
  const { ctx, store, scope, tab, expanded, revealed, onToggleDir, onReferenceFile } = props
113
113
  const path = tab.path ?? ''
@@ -127,8 +127,8 @@ export function FileTree(props: {
127
127
  onOpenWith?: (targetId: string, path: string) => void
128
128
  /** Toggle one target's pinned state (the submenu row's pushpin). */
129
129
  onToggleOpenWithPin?: (targetId: string) => void
130
- /** Insert `@<relative path>` into the composer draft. */
131
- onReferenceFile: (path: string) => void
130
+ /** Insert `@<relative path>` into the composer draft (file vs directory). */
131
+ onReferenceFile: (path: string, isDir: boolean) => void
132
132
  /** Bump to wipe the level cache and reload the visible set. */
133
133
  refreshTick: number
134
134
  /** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
@@ -310,7 +310,7 @@ export function FileTree(props: {
310
310
  title={t('referenceFile')}
311
311
  onClick={(event) => {
312
312
  event.stopPropagation()
313
- onReferenceFile(entry.path)
313
+ onReferenceFile(entry.path, entry.isDir)
314
314
  }}
315
315
  >
316
316
  {t('referenceFile')}
@@ -532,7 +532,7 @@ export function FileTree(props: {
532
532
  title={t('referenceFile')}
533
533
  onClick={(event) => {
534
534
  event.stopPropagation()
535
- onReferenceFile(root)
535
+ onReferenceFile(root, true)
536
536
  }}
537
537
  >
538
538
  {t('referenceFile')}
@@ -25,7 +25,7 @@ import { useSyncExternalStore } from 'react'
25
25
  import clsx from 'clsx'
26
26
  import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
27
27
  import type { Context, SidebarSessionList } from '../context-types.ts'
28
- import { appendToDraft } from './conversation-draft.ts'
28
+ import { appendToDraft, insertFileReference } from './conversation-draft.ts'
29
29
  import {
30
30
  PANEL_MIN, activateTab, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
31
31
  isAgentTabId, leafWithTab,
@@ -119,7 +119,7 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
119
119
  * pane; sessionId/cwd cover onReferenceFile). */
120
120
  interface TabContentProps extends TabContentMemoKey {
121
121
  onToggleDir: (path: string) => void
122
- onReferenceFile: (path: string) => void
122
+ onReferenceFile: (path: string, isDir: boolean) => void
123
123
  ctx: Context
124
124
  store: SidebarStore
125
125
  /** Fired before a topology node jumps to its child session (see Sidebar). */
@@ -498,8 +498,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
498
498
  * Subagent auto-activation: the moment the current conversation spawns its
499
499
  * FIRST direct subagent (a 0 → N transition on the list feed), the "auto
500
500
  * open" pref is on, and the Subagent tab type is enabled in settings,
501
- * open the panel (if collapsed) and focus the Subagent page
502
- * (single-instance: an existing tab is focused, never duplicated).
501
+ * focus the Subagent page (single-instance: an existing tab is focused,
502
+ * never duplicated). On wide viewports the right panel also expands; on
503
+ * narrow viewports background activity never forces the full-screen drawer
504
+ * open over the chat.
503
505
  * Switching to a session that already has subagents never triggers — its
504
506
  * baseline starts at the current count — so a deliberate layout is never
505
507
  * fought.
@@ -525,9 +527,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
525
527
  if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
526
528
  if (!store.getPrefs().autoOpenSubagent) return
527
529
  if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
528
- store.reduce(s => s.panelOpen ? s : togglePanel(s))
529
- // Pin the landing to the first pane: the auto-opened Subagent page
530
- // must appear in the panel that just expanded, not wherever the user
530
+ // Read the viewport when the delayed activation fires: a resize while
531
+ // the debounce is armed must not let background activity force the
532
+ // narrow full-screen drawer open over the chat.
533
+ if (!isNarrowWidth(window.innerWidth)) {
534
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
535
+ }
536
+ // Pin the landing to the first pane: the auto-activated Subagent page
537
+ // must be ready in the panel that just expanded, not wherever the user
531
538
  // last touched.
532
539
  store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
533
540
  ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
@@ -545,11 +552,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
545
552
  /**
546
553
  * Job auto-activation: the moment a NEW background job appears for the
547
554
  * current conversation (a job id the previous snapshot lacked), the
548
- * auto-open pref is on, and the Jobs tab type is enabled, open the panel
549
- * (if collapsed) and focus the Jobs page. Unlike the subagent trigger
550
- * (0 N only), ANY new job id triggers: the agent may start several
551
- * jobs in one session, and each should surface. A fresh page load never
552
- * triggers its baseline starts at the current snapshot.
555
+ * auto-open pref is on, and the Jobs tab type is enabled, focus the Jobs
556
+ * page. The right panel expands only on wide viewports background
557
+ * activity never forces the narrow full-screen drawer open. Unlike the
558
+ * subagent trigger (0 → N only), ANY new job id triggers: the agent may
559
+ * start several jobs in one session, and each should surface. A fresh page
560
+ * load never triggers — its baseline starts at the current snapshot.
553
561
  */
554
562
  const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
555
563
  useEffect(() => {
@@ -559,7 +567,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
559
567
  if (!detectNewJob(prev, sessionList, sessionId)) return
560
568
  if (!store.getPrefs().autoOpenJobs) return
561
569
  if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
562
- store.reduce(s => s.panelOpen ? s : togglePanel(s))
570
+ if (!isNarrowWidth(window.innerWidth)) {
571
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
572
+ }
563
573
  store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
564
574
  ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
565
575
  }, [sessionList, sessionId, store, ctx])
@@ -1128,17 +1138,26 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
1128
1138
  }, [actions, pinnedVirtualTabs, activePinnedTabId, store])
1129
1139
 
1130
1140
  /**
1131
- * The explorer's @-reference button: append `@<relative path>` to the
1132
- * session's composer draft (space-separated). The conversation service is
1133
- * resolved lazily through `ctx.get` (the inject-free read the app's own
1134
- * plugins read 'conversation' the same way); a missing service or scope
1135
- * degrades to a logged no-op, never a crash. Defined above the no-session
1136
- * early return a hook must never sit behind a conditional return
1137
- * (React counts hooks per render).
1141
+ * The explorer's @-reference button. Directories append the folder mention
1142
+ * (`@dir/`) as plain text so DSH's folder decoration and completion keep
1143
+ * working; files insert a structured chip like the native `@` picker, so
1144
+ * the whole reference stays one link instead of decorating only the
1145
+ * leading folder. Resolves the session-scope ctx and the conversation
1146
+ * input service at click time; a missing service or scope degrades to a
1147
+ * logged no-op, never a crash. Defined above the no-session early return
1148
+ * — a hook must never sit behind a conditional return (React counts hooks
1149
+ * per render).
1138
1150
  */
1139
- const referenceInChat = useCallback((path: string): void => {
1151
+ const referenceInChat = useCallback((path: string, isDir: boolean): void => {
1140
1152
  if (sessionId === undefined) return
1141
- appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
1153
+ const rel = relativeTo(cwd ?? '', path)
1154
+ if (isDir) {
1155
+ appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`)
1156
+ return
1157
+ }
1158
+ if (!insertFileReference(ctx, sessionId, rel)) {
1159
+ appendToDraft(ctx, sessionId, `@${rel}`)
1160
+ }
1142
1161
  }, [ctx, sessionId, cwd])
1143
1162
 
1144
1163
  if (state === undefined || sessionId === undefined) {
@@ -60,6 +60,13 @@ const FAILURE_LIMIT = 3
60
60
  */
61
61
  const PTY_DEPS_MISSING = 'pty-deps-missing'
62
62
 
63
+ /**
64
+ * The WS close-reason prefix the host sends when the CONFIGURED shell was
65
+ * not found (mirror of src/index.ts wsCloseReasonOf; wire contract, keep the
66
+ * literal in lockstep). The view renders a localized, actionable banner.
67
+ */
68
+ const SHELL_NOT_FOUND_PREFIX = 'shell-not-found:'
69
+
63
70
  /** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
64
71
  type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
65
72
 
@@ -241,6 +248,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
241
248
  })
242
249
  return
243
250
  }
251
+ // The configured shell could not be found (settings page or yaml):
252
+ // a localized banner beats the raw English close reason.
253
+ if (event.code === 1011 && event.reason.startsWith(SHELL_NOT_FOUND_PREFIX)) {
254
+ setFatal(t('terminalShellNotFound', { name: event.reason.slice(SHELL_NOT_FOUND_PREFIX.length) || '?' }))
255
+ return
256
+ }
244
257
  // A server-side refusal carries a close code + reason; retrying it
245
258
  // forever would only spin the banner, so surface it with a retry.
246
259
  if (event.code === 1011 && event.reason !== '') {