dsh-plugin-workbench 0.0.12 → 0.0.13

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/lib/index.js CHANGED
@@ -230,7 +230,37 @@ function ensureLayoutPatch() {
230
230
  layoutPatchScheduled = false;
231
231
  }
232
232
  }
233
- /** File-mention grammar taught to the model (see the FILE_MENTION_PROMPT doc). */
233
+ /**
234
+ * Stable system-prompt section teaching the model the workbench `@.\` mention
235
+ * grammar. The core already contributes a generic "paths prefixed with @ are
236
+ * files referenced by the user" section (dsh-file-reference-local, order 99);
237
+ * this one documents the actual syntax the GUI inserts (the `.\` workspace
238
+ * marker, quoted form for paths with spaces) so the model resolves mentions
239
+ * against the session workspace root instead of treating `@.\…` as noise.
240
+ * Static text only — the string is identical on every assembly (KV-cache safe).
241
+ */
242
+ const FILE_MENTION_PROMPT = "Workspace file mentions written by the file panel use the form `@.<relative-path>` (a `.` or `./` prefix marks a path relative to the current session workspace root; both `\\` and `/` separators are accepted, and paths with spaces use the quoted form `@\"<relative-path>\"`, for example `@\"\\.\\my plan.md\"`). Treat any such mention as a file the user wants you to read or edit: resolve the path against the workspace root and act on it with the file tools (read, glob, grep, edit, write); never claim to have inspected a file you did not actually open.";
243
+ /**
244
+ * File-mention grammar taught to the model (see the FILE_MENTION_PROMPT doc).
245
+ * One section per agent fiber: existing agents at apply time plus every agent
246
+ * created later (agent/created). The core already contributes a generic "paths
247
+ * prefixed with @ are files" section (dsh-file-reference-local, order 99);
248
+ * order 100 puts this more specific grammar right after it.
249
+ */
250
+ function installMentionPrompt(ctx) {
251
+ const teach = (agent) => {
252
+ agent.ctx.systemPrompt.section({
253
+ name: "dsh-plugin-workbench:file-mention",
254
+ order: 100,
255
+ text: FILE_MENTION_PROMPT
256
+ });
257
+ };
258
+ for (const agent of ctx.agents.list()) teach(agent);
259
+ ctx.on("agent/created", (payload) => {
260
+ const { agent } = payload;
261
+ teach(agent);
262
+ });
263
+ }
234
264
  /**
235
265
  * One filesystem-backed RPC endpoint pair. Reads never mutate; `signal`
236
266
  * cancels the underlying fs call (or aborts between steps).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-workbench",
3
3
  "description": "VS Code-style workspace file explorer + editable preview for the dsh web GUI",
4
- "version": "0.0.12",
4
+ "version": "0.0.13",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.21.0",
7
7
  "engines": {
@@ -47,6 +47,22 @@ const PREVIEW_MIN = 240
47
47
  const CHAT_MIN = 240
48
48
  const PREVIEW_TOO_LARGE_LABEL = '512KB'
49
49
 
50
+ /** Persisted split width (px) — survives reloads so a drag is never lost. */
51
+ const PREVIEW_WIDTH_KEY = 'dsh-plugin-workbench:preview-width'
52
+
53
+ /** Read the persisted preview width; `null` means "use the default 55%". */
54
+ function storedPreviewWidth(): number | null {
55
+ try {
56
+ if (typeof window === 'undefined') return null
57
+ const raw = window.localStorage.getItem(PREVIEW_WIDTH_KEY)
58
+ if (raw === null) return null
59
+ const px = Number(raw)
60
+ return Number.isFinite(px) && px > 0 ? px : null
61
+ } catch {
62
+ return null
63
+ }
64
+ }
65
+
50
66
  /** Same-origin raw-bytes route registered by the host half (see src/index.ts). */
51
67
  const RAW_PREFIX = '/dsh-plugin-files/raw'
52
68
 
@@ -108,6 +124,23 @@ function clamp(value: number, min: number, max: number): number {
108
124
  return Math.min(max, Math.max(min, value))
109
125
  }
110
126
 
127
+ /**
128
+ * Nearest ancestor that actually takes part in layout. The slot system wraps
129
+ * each slot's content in a `display: contents` element: children still join
130
+ * the OUTER flex row, but the wrapper itself reports a 0×0 bounding rect.
131
+ * Measuring that (the old `handle.parentElement`) made `max` collapse to
132
+ * `PREVIEW_MIN` on the first move — the pane jumped to its minimum width and
133
+ * could never be dragged back out. Skip every `display: contents` layer.
134
+ */
135
+ function laidOutParent(el: HTMLElement | null): HTMLElement | null {
136
+ let node = el?.parentElement ?? null
137
+ while (node !== null) {
138
+ if (getComputedStyle(node).display !== 'contents') return node
139
+ node = node.parentElement
140
+ }
141
+ return null
142
+ }
143
+
111
144
  /** Parent directory of a path ('C:/a/b.md' → 'C:/a'; '' when there is none). */
112
145
  function dirnameOf(path: string): string {
113
146
  const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
@@ -163,6 +196,10 @@ export function FilePreview({ t, readFile, writeFile, watchFiles }: FilePreviewP
163
196
  const highlightRef = useRef<HTMLPreElement>(null)
164
197
  const textareaRef = useRef<HTMLTextAreaElement>(null)
165
198
  const gutterRef = useRef<HTMLDivElement>(null)
199
+ // Live drag listeners, kept in refs so a new drag can ALWAYS clear any
200
+ // leftovers (a lost pointerup must not leak a stale onMove into the page).
201
+ const dragMoveRef = useRef<(ev: PointerEvent) => void>(() => undefined)
202
+ const dragUpRef = useRef<() => void>(() => undefined)
166
203
 
167
204
  const refresh = useCallback(() => bump((v) => v + 1), [])
168
205
 
@@ -391,26 +428,89 @@ export function FilePreview({ t, readFile, writeFile, watchFiles }: FilePreviewP
391
428
  }
392
429
  }, [isOpen])
393
430
 
431
+ // Apply the persisted width once the pane gets its first real layout: the
432
+ // saved px may exceed the CURRENT center column (window resized since the
433
+ // last drag), so clamp it against a live measurement before applying —
434
+ // otherwise a stale wide value would squeeze the chat seat to nothing.
435
+ useEffect(() => {
436
+ const saved = storedPreviewWidth()
437
+ if (saved === null) return
438
+ const preview = previewRef.current
439
+ const center = laidOutParent(preview)
440
+ if (preview === null || preview === undefined || center === null || center === undefined) return
441
+ const centerWidth = center.getBoundingClientRect().width
442
+ const max = Math.max(PREVIEW_MIN, centerWidth - CHAT_MIN)
443
+ setPreviewWidth(clamp(saved, PREVIEW_MIN, max))
444
+ }, [isOpen])
445
+
446
+ // Unmount during a drag (slot removed mid-drag): drop the window listeners
447
+ // so no stale move handler survives to rewrite widths in the next mount.
448
+ useEffect(() => () => {
449
+ window.removeEventListener('pointermove', dragMoveRef.current)
450
+ window.removeEventListener('pointerup', dragUpRef.current)
451
+ window.removeEventListener('pointercancel', dragUpRef.current)
452
+ }, [])
453
+
454
+ /**
455
+ * Start a split-width drag. Robustness notes (the "pane suddenly shrinks
456
+ * and freezes" bug class):
457
+ *
458
+ * - Pointer capture reroutes every later pointer event to the handle, so
459
+ * `pointerup` fires EVEN when the mouse is released outside the window.
460
+ * Without it the up event is lost, `onUp` never runs, and the leftover
461
+ * `onMove` keeps rewriting the width from its stale baseline on every
462
+ * mouse move anywhere on the page — that is the "cannot drag / cannot
463
+ * restore" state.
464
+ * - `pointercancel` is cleaned up too (browser steals the pointer, e.g. a
465
+ * tablet palm or an OS gesture).
466
+ * - Pointerdown defensively removes any previous listeners first, so even a
467
+ * capture-less leftover cannot survive into a second drag.
468
+ * - The max is re-measured every move: the chat minimum is relative to the
469
+ * CURRENT center column, which can change while the drag is in flight.
470
+ */
394
471
  const onHandleDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
395
472
  e.preventDefault()
396
473
  const handle = handleRef.current
397
474
  const preview = previewRef.current
398
475
  if (handle === null || preview === null) return
399
- const center = handle.parentElement
476
+ // NOT handle.parentElement: the slot wrapper is `display: contents` and
477
+ // measures 0×0 — the flex container to clamp against is one level up (see
478
+ // laidOutParent). Measuring the wrapper made every drag snap to
479
+ // PREVIEW_MIN and then stick (the reported "sudden shrink / can't drag").
480
+ const center = laidOutParent(handle)
400
481
  if (center === null) return
401
482
  const startX = e.clientX
402
483
  const startWidth = preview.getBoundingClientRect().width
403
- const centerWidth = center.getBoundingClientRect().width
404
- const max = Math.max(PREVIEW_MIN, centerWidth - CHAT_MIN)
405
484
  const onMove = (ev: PointerEvent) => {
406
- setPreviewWidth(clamp(startWidth + ev.clientX - startX, PREVIEW_MIN, max))
485
+ const centerWidth = center.getBoundingClientRect().width
486
+ const max = Math.max(PREVIEW_MIN, centerWidth - CHAT_MIN)
487
+ const width = clamp(startWidth + ev.clientX - startX, PREVIEW_MIN, max)
488
+ setPreviewWidth(width)
489
+ try {
490
+ window.localStorage.setItem(PREVIEW_WIDTH_KEY, String(width))
491
+ } catch {
492
+ // storage unavailable — the width just won't persist across reloads
493
+ }
407
494
  }
408
495
  const onUp = () => {
409
- window.removeEventListener('pointermove', onMove)
410
- window.removeEventListener('pointerup', onUp)
496
+ window.removeEventListener('pointermove', dragMoveRef.current)
497
+ window.removeEventListener('pointerup', dragUpRef.current)
498
+ window.removeEventListener('pointercancel', dragUpRef.current)
411
499
  }
500
+ // Defensive reset of any previous drag (see the doc comment above).
501
+ window.removeEventListener('pointermove', dragMoveRef.current)
502
+ window.removeEventListener('pointerup', dragUpRef.current)
503
+ window.removeEventListener('pointercancel', dragUpRef.current)
504
+ dragMoveRef.current = onMove
505
+ dragUpRef.current = onUp
412
506
  window.addEventListener('pointermove', onMove)
413
507
  window.addEventListener('pointerup', onUp)
508
+ window.addEventListener('pointercancel', onUp)
509
+ try {
510
+ handle.setPointerCapture(e.pointerId)
511
+ } catch {
512
+ // Pointer capture unsupported — the window listeners still cover the drag.
513
+ }
414
514
  }, [])
415
515
 
416
516
  const onSave = useCallback(async () => {
package/src/index.ts CHANGED
@@ -342,7 +342,27 @@ function ensureLayoutPatch(): void {
342
342
  const FILE_MENTION_PROMPT =
343
343
  'Workspace file mentions written by the file panel use the form `@.<relative-path>` (a `.\` or `./` prefix marks a path relative to the current session workspace root; both `\\` and `/` separators are accepted, and paths with spaces use the quoted form `@"<relative-path>"`, for example `@"\\.\\my plan.md"`). Treat any such mention as a file the user wants you to read or edit: resolve the path against the workspace root and act on it with the file tools (read, glob, grep, edit, write); never claim to have inspected a file you did not actually open.'
344
344
 
345
- /** File-mention grammar taught to the model (see the FILE_MENTION_PROMPT doc). */
345
+ /**
346
+ * File-mention grammar taught to the model (see the FILE_MENTION_PROMPT doc).
347
+ * One section per agent fiber: existing agents at apply time plus every agent
348
+ * created later (agent/created). The core already contributes a generic "paths
349
+ * prefixed with @ are files" section (dsh-file-reference-local, order 99);
350
+ * order 100 puts this more specific grammar right after it.
351
+ */
352
+ function installMentionPrompt(ctx: Context): void {
353
+ const teach = (agent: { ctx: Context }): void => {
354
+ agent.ctx.systemPrompt.section({
355
+ name: 'dsh-plugin-workbench:file-mention',
356
+ order: 100,
357
+ text: FILE_MENTION_PROMPT,
358
+ })
359
+ }
360
+ for (const agent of ctx.agents.list()) teach(agent)
361
+ ctx.on('agent/created', (payload: unknown) => {
362
+ const { agent } = payload as { agent: { ctx: Context } }
363
+ teach(agent)
364
+ })
365
+ }
346
366
 
347
367
  /**
348
368
  * One filesystem-backed RPC endpoint pair. Reads never mutate; `signal`