@flowdular/sandbox 0.2.6 → 0.2.7

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.
@@ -85,19 +85,33 @@ export function createByokDriver(
85
85
  allowedPaths: readonly string[],
86
86
  emit: (event: CodingAgentEvent) => void,
87
87
  ): ToolSet {
88
+ let nextCall = 0;
88
89
  const record = (
89
90
  name: string,
90
91
  detail: string,
91
92
  run: () => Promise<string>,
92
93
  ) => {
93
- emit({ type: 'tool.started', tool: name, detail });
94
+ const callId = String(++nextCall);
95
+ emit({ type: 'tool.started', callId, tool: name, detail });
94
96
  return run().then(
95
97
  (value) => {
96
- emit({ type: 'tool.completed', tool: name, detail, ok: true });
98
+ emit({
99
+ type: 'tool.completed',
100
+ callId,
101
+ tool: name,
102
+ detail,
103
+ ok: true,
104
+ });
97
105
  return value;
98
106
  },
99
107
  (error: unknown) => {
100
- emit({ type: 'tool.completed', tool: name, detail, ok: false });
108
+ emit({
109
+ type: 'tool.completed',
110
+ callId,
111
+ tool: name,
112
+ detail,
113
+ ok: false,
114
+ });
101
115
  return `Error: ${error instanceof Error ? error.message : String(error)}`;
102
116
  },
103
117
  );
@@ -28,6 +28,8 @@ const DEFAULT_TOOLS = ['Read', 'Write', 'Edit', 'Glob', 'Grep'] as const;
28
28
  const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
29
29
 
30
30
  interface ContentBlock {
31
+ readonly id?: string;
32
+ readonly tool_use_id?: string;
31
33
  readonly type?: string;
32
34
  readonly text?: string;
33
35
  readonly thinking?: string;
@@ -41,7 +43,8 @@ function toolDetail(input: Record<string, unknown> | undefined): string {
41
43
  if (!input) return '';
42
44
  for (const key of ['file_path', 'path', 'pattern', 'query', 'command']) {
43
45
  const value = input[key];
44
- if (typeof value === 'string') return value.slice(0, 200);
46
+ if (typeof value === 'string')
47
+ return value.slice(0, key === 'file_path' || key === 'path' ? 4096 : 200);
45
48
  }
46
49
  return '';
47
50
  }
@@ -175,6 +178,7 @@ export function createClaudeCodeDriver(
175
178
  let started = false;
176
179
  let completed = false;
177
180
  let currentResumeId = resumeId;
181
+ let lastActivityAt = -Infinity;
178
182
  for await (const line of stream.lines) {
179
183
  const message = parseJsonLine(line);
180
184
  if (!message) continue;
@@ -200,14 +204,30 @@ export function createClaudeCodeDriver(
200
204
  const block = partial?.content_block as
201
205
  | Record<string, unknown>
202
206
  | undefined;
207
+
208
+ const delta = partial?.delta as Record<string, unknown> | undefined;
209
+ const kind =
210
+ partial?.type === 'content_block_start'
211
+ ? block?.type
212
+ : partial?.type === 'content_block_delta'
213
+ ? delta?.type
214
+ : null;
215
+ const phase =
216
+ kind === 'thinking' || kind === 'thinking_delta'
217
+ ? 'thinking'
218
+ : kind === 'text' ||
219
+ kind === 'text_delta' ||
220
+ kind === 'input_json_delta'
221
+ ? 'responding'
222
+ : null;
223
+ const now = Date.now();
203
224
  if (
204
- partial?.type === 'content_block_start' &&
205
- (block?.type === 'thinking' || block?.type === 'text')
225
+ phase &&
226
+ (partial?.type === 'content_block_start' ||
227
+ now - lastActivityAt >= 10_000)
206
228
  ) {
207
- yield {
208
- type: 'activity',
209
- phase: block.type === 'thinking' ? 'thinking' : 'responding',
210
- };
229
+ lastActivityAt = now;
230
+ yield { type: 'activity', phase };
211
231
  }
212
232
  continue;
213
233
  }
@@ -219,12 +239,13 @@ export function createClaudeCodeDriver(
219
239
  if (block.type === 'text' && block.text?.trim()) {
220
240
  yield { type: 'assistant.message', text: block.text };
221
241
  }
222
- if (block.type === 'thinking' && typeof block.thinking === 'string') {
242
+ if (block.type === 'thinking' && block.thinking?.trim()) {
223
243
  yield { type: 'reasoning', text: block.thinking };
224
244
  }
225
245
  if (block.type === 'tool_use' && block.name) {
226
246
  yield {
227
247
  type: 'tool.started',
248
+ ...(block.id ? { callId: block.id } : {}),
228
249
  tool: block.name,
229
250
  detail: toolDetail(block.input),
230
251
  };
@@ -244,6 +265,7 @@ export function createClaudeCodeDriver(
244
265
  if (block.type !== 'tool_result') continue;
245
266
  yield {
246
267
  type: 'tool.completed',
268
+ ...(block.tool_use_id ? { callId: block.tool_use_id } : {}),
247
269
  tool: typeof result.type === 'string' ? result.type : 'tool',
248
270
  detail: typeof result.filePath === 'string' ? result.filePath : '',
249
271
  ok: block.is_error !== true,
@@ -290,6 +312,12 @@ export function createClaudeCodeDriver(
290
312
 
291
313
  const exit = await stream.finished;
292
314
  if (completed) return;
315
+ if (exit.timedOut) {
316
+ throw new CodingAgentError(
317
+ 'DRIVER_TIMEOUT',
318
+ 'The coding agent exceeded the turn time limit. Review the draft before continuing.',
319
+ );
320
+ }
293
321
  if (exit.aborted) {
294
322
  yield {
295
323
  type: 'turn.completed',
@@ -217,12 +217,14 @@ export function createCodexDriver(
217
217
  yield done
218
218
  ? {
219
219
  type: 'tool.completed',
220
+ ...(item.id ? { callId: item.id } : {}),
220
221
  tool: 'command',
221
222
  detail: (item.command ?? '').slice(0, 200),
222
223
  ok: (item.exit_code ?? 0) === 0,
223
224
  }
224
225
  : {
225
226
  type: 'tool.started',
227
+ ...(item.id ? { callId: item.id } : {}),
226
228
  tool: 'command',
227
229
  detail: (item.command ?? '').slice(0, 200),
228
230
  };
@@ -262,6 +264,12 @@ export function createCodexDriver(
262
264
 
263
265
  const exit = await stream.finished;
264
266
  if (completed) return;
267
+ if (exit.timedOut) {
268
+ throw new CodingAgentError(
269
+ 'DRIVER_TIMEOUT',
270
+ 'The coding agent exceeded the turn time limit. Review the draft before continuing.',
271
+ );
272
+ }
265
273
  if (exit.aborted) {
266
274
  yield {
267
275
  type: 'turn.completed',
@@ -20,11 +20,13 @@ export type CodingAgentEvent =
20
20
  | { readonly type: 'activity'; readonly phase: 'thinking' | 'responding' }
21
21
  | {
22
22
  readonly type: 'tool.started';
23
+ readonly callId?: string;
23
24
  readonly tool: string;
24
25
  readonly detail: string;
25
26
  }
26
27
  | {
27
28
  readonly type: 'tool.completed';
29
+ readonly callId?: string;
28
30
  readonly tool: string;
29
31
  readonly detail: string;
30
32
  readonly ok: boolean;
@@ -146,6 +146,7 @@ export interface ProcessLineStream {
146
146
  readonly code: number | null;
147
147
  readonly stderr: string;
148
148
  readonly aborted: boolean;
149
+ readonly timedOut: boolean;
149
150
  }>;
150
151
  }
151
152
 
@@ -171,6 +172,8 @@ export function spawnLineStream(options: SpawnJsonOptions): ProcessLineStream {
171
172
  const stderrLimit = options.stderrLimit ?? 8_192;
172
173
  let stderr = '';
173
174
  let aborted = false;
175
+ let timedOut = false;
176
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
174
177
  child.stderr.setEncoding('utf8');
175
178
  child.stderr.on('data', (chunk: string) => {
176
179
  if (stderr.length < stderrLimit) {
@@ -179,27 +182,40 @@ export function spawnLineStream(options: SpawnJsonOptions): ProcessLineStream {
179
182
  });
180
183
 
181
184
  const stop = () => {
185
+ if (aborted) return;
182
186
  aborted = true;
183
187
  child.kill('SIGTERM');
184
- setTimeout(() => child.kill('SIGKILL'), 2_000).unref();
188
+ killTimer = setTimeout(() => child.kill('SIGKILL'), 2_000);
189
+ killTimer.unref();
185
190
  };
186
191
  const timer =
187
192
  options.timeoutMs === undefined
188
193
  ? undefined
189
- : setTimeout(stop, options.timeoutMs);
194
+ : setTimeout(() => {
195
+ if (aborted) return;
196
+ timedOut = true;
197
+ stop();
198
+ }, options.timeoutMs);
190
199
  timer?.unref();
191
200
  if (options.signal) {
192
201
  if (options.signal.aborted) stop();
193
202
  else options.signal.addEventListener('abort', stop, { once: true });
194
203
  }
195
204
 
205
+ const cleanup = () => {
206
+ if (timer) clearTimeout(timer);
207
+ if (killTimer) clearTimeout(killTimer);
208
+ options.signal?.removeEventListener('abort', stop);
209
+ };
210
+
196
211
  const finished = new Promise<{
197
212
  code: number | null;
198
213
  stderr: string;
199
214
  aborted: boolean;
215
+ timedOut: boolean;
200
216
  }>((resolvePromise, rejectPromise) => {
201
217
  child.on('error', (error) => {
202
- if (timer) clearTimeout(timer);
218
+ cleanup();
203
219
  rejectPromise(
204
220
  new CodingAgentError(
205
221
  'DRIVER_PROCESS_FAILED',
@@ -208,8 +224,8 @@ export function spawnLineStream(options: SpawnJsonOptions): ProcessLineStream {
208
224
  );
209
225
  });
210
226
  child.on('close', (code) => {
211
- if (timer) clearTimeout(timer);
212
- resolvePromise({ code, stderr, aborted });
227
+ cleanup();
228
+ resolvePromise({ code, stderr, aborted, timedOut });
213
229
  });
214
230
  });
215
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowdular/sandbox",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "The Flowdular sandbox: chat a change, build it behind the gates, preview it in the real application, deliver it as code.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/flowdular/flowdular/tree/main/packages/sandbox#readme",
@@ -17,6 +17,10 @@ import {
17
17
  type ModuleSpecReview,
18
18
  type RoleSummary,
19
19
  } from './api.ts';
20
+ import { ToolEvent } from './ToolEvent.tsrx';
21
+ import { transcriptRows, type TranscriptRow } from './transcript.ts';
22
+ import { TurnActivity } from './TurnActivity.tsrx';
23
+ import { ComposerSettings } from './ComposerSettings.tsrx';
20
24
  import { ApprovalHandoff } from './ApprovalHandoff.tsrx';
21
25
  import { ATTACH_ACCEPT, attachmentKind, formatBytes } from './attachments.ts';
22
26
  import {
@@ -115,29 +119,33 @@ function spoken(entry: ChatEntry): string {
115
119
  interface StreamBlock {
116
120
  readonly key: number;
117
121
  readonly entry: ChatEntry | null;
118
- readonly events: readonly ChatEntry[] | null;
122
+ readonly events: readonly TranscriptRow[] | null;
119
123
  }
120
124
 
121
- const EVENT_TAIL = 6;
122
-
123
125
  /* One stream in the order it happened: a message, then the tool activity that
124
126
  followed it, so the newest thing on screen is the newest thing that happened.
125
- A long run of tool events keeps only its tail, because the transcript is the
126
- conversation and the events are its footnotes. */
127
+ Keep the complete history; paired calls retain their original position. */
127
128
  function streamBlocks(entries: readonly ChatEntry[]): readonly StreamBlock[] {
128
129
  const blocks: {
129
130
  key: number;
130
131
  entry: ChatEntry | null;
131
- events: ChatEntry[] | null;
132
+ events: TranscriptRow[] | null;
132
133
  }[] = [];
133
- for (const entry of entries) {
134
+ for (const row of transcriptRows(entries)) {
135
+ const entry = row.entry;
136
+ if (entry.event?.type === 'reasoning' && !entry.event.text.trim()) continue;
134
137
  const last = blocks[blocks.length - 1];
135
138
  if (entry.kind === 'event') {
136
139
  if (last?.events) {
137
- last.events.push(entry);
140
+ if (
141
+ entry.event?.type === 'activity' &&
142
+ last.events.at(-1)?.entry.event?.type === 'activity'
143
+ ) {
144
+ last.events[last.events.length - 1] = row;
145
+ } else last.events.push(row);
138
146
  continue;
139
147
  }
140
- blocks.push({ key: entry.sequence, entry: null, events: [entry] });
148
+ blocks.push({ key: entry.sequence, entry: null, events: [row] });
141
149
  continue;
142
150
  }
143
151
  blocks.push({ key: entry.sequence, entry, events: null });
@@ -146,7 +154,7 @@ function streamBlocks(entries: readonly ChatEntry[]): readonly StreamBlock[] {
146
154
  (block) => ({
147
155
  key: block.key,
148
156
  entry: block.entry,
149
- events: block.events ? block.events.slice(-EVENT_TAIL) : null,
157
+ events: block.events,
150
158
  }),
151
159
  );
152
160
  }
@@ -167,6 +175,10 @@ export function ChatPane(props: ChatPaneProps) @{
167
175
  const { t } = useTranslation();
168
176
  const offered = props.drivers.filter((driver) => driver.offered);
169
177
  const stream = streamBlocks(props.entries);
178
+ const isActive = (entry: ChatEntry) =>
179
+ props.running && entry.sequence === props.entries.at(-1)?.sequence &&
180
+ (entry.event?.type === 'activity' ||
181
+ entry.event?.type === 'tool.started');
170
182
  /* Which module an entry belongs to only matters once a session has several;
171
183
  with one module the transcript stays as quiet as it was. */
172
184
  const moduleLabel = (entry: ChatEntry) => props.modules.length > 1 &&
@@ -178,6 +190,7 @@ export function ChatPane(props: ChatPaneProps) @{
178
190
  const open =
179
191
  props.entries.filter((entry) => entry.handoff).at(-1)?.sequence ?? -1;
180
192
  const scroller = useRef<HTMLDivElement | null>(null);
193
+ const followLatest = useRef(true);
181
194
  const attachInput = useRef<HTMLInputElement | null>(null);
182
195
  /* The composer manages its own attachments: App owns the transcript, but the
183
196
  session id from the URL is enough to upload, list and remove them here. */
@@ -206,11 +219,14 @@ export function ChatPane(props: ChatPaneProps) @{
206
219
  ? checkpoints.find((entry) => entry.sequence === 0)
207
220
  : undefined);
208
221
 
209
- /* The newest message is the one being written, so the transcript follows it. */
222
+ /* Follow new events only while the operator is already at the bottom. */
223
+ useEffect(() => {
224
+ followLatest.current = true;
225
+ }, [sessionId]);
210
226
  useEffect(() => {
211
227
  const node = scroller.current;
212
- if (node) node.scrollTop = node.scrollHeight;
213
- }, [props.entries.length, props.running]);
228
+ if (node && followLatest.current) node.scrollTop = node.scrollHeight;
229
+ }, [props.entries.length, props.running, sessionId]);
214
230
 
215
231
  /* Seed the strip from the open session, and reset it when the session
216
232
  changes so one session's attachments never show under another. */
@@ -391,22 +407,50 @@ export function ChatPane(props: ChatPaneProps) @{
391
407
  };
392
408
 
393
409
  <section class="sandbox__chat">
394
- <div class="sandbox__scroll" ref={scroller}>
410
+ <div
411
+ class="sandbox__scroll"
412
+ ref={scroller}
413
+ onScroll={(event) => {
414
+ const node = event.currentTarget;
415
+ followLatest.current = node.scrollHeight - node.scrollTop -
416
+ node.clientHeight <
417
+ 64;
418
+ }}
419
+ >
395
420
  <div class="chat sandbox__column">
396
421
  @for (const block of stream; key blockKey(block)) {
397
422
  @if (block.events) {
398
423
  <ul class="chat__events">
399
- @for (const entry of block.events; key entry.sequence) {
400
- <li
401
- class={[
402
- 'chat__event',
403
- isError(entry) && 'chat__event--error',
404
- ]}
405
- title={eventLabel(entry)}
406
- >
407
- <Icon name={isError(entry) ? 'alert' : 'check'} size={14} />
408
- <span class="chat__event-text">{eventLabel(entry)}</span>
409
- </li>
424
+ @for (const row of block.events; key row.key) {
425
+ @if (
426
+ row.entry.event?.type === 'tool.started' ||
427
+ row.entry.event?.type === 'tool.completed'
428
+ ) {
429
+ <li>
430
+ <ToolEvent row={row} running={props.running} />
431
+ </li>
432
+ } @else {
433
+ <li
434
+ class={[
435
+ 'chat__event',
436
+ isActive(row.entry) && 'chat__event--active',
437
+ isError(row.entry) && 'chat__event--error',
438
+ ]}
439
+ title={eventLabel(row.entry)}
440
+ >
441
+ <Icon
442
+ name={isError(row.entry)
443
+ ? 'alert'
444
+ : isActive(row.entry)
445
+ ? 'refresh'
446
+ : 'check'}
447
+ size={14}
448
+ />
449
+ <span class="chat__event-text">{eventLabel(
450
+ row.entry,
451
+ )}</span>
452
+ </li>
453
+ }
410
454
  }
411
455
  </ul>
412
456
  } @else if (block.entry!.handoff) {
@@ -606,16 +650,14 @@ export function ChatPane(props: ChatPaneProps) @{
606
650
  onInput={(event) => props.onMessage(event.currentTarget.value)}
607
651
  onPaste={onPaste}
608
652
  ></textarea>
609
- <label class="ui-checkbox" title={t('sandbox.chat.freshHelp')}>
610
- <input
611
- type="checkbox"
612
- checked={freshContext}
613
- disabled={props.running}
614
- onChange={(event) => setFreshContext(event.currentTarget.checked)}
615
- />
616
- <span>{t('sandbox.chat.fresh')}</span>
617
- </label>
618
653
  <div class="chat__composer-row">
654
+ <ComposerSettings
655
+ autoContinue={props.autoContinue}
656
+ freshContext={freshContext}
657
+ running={props.running}
658
+ onAutoContinue={props.onAutoContinue}
659
+ onFreshContext={setFreshContext}
660
+ />
619
661
  <button
620
662
  class="chat__attach"
621
663
  type="button"
@@ -668,16 +710,6 @@ export function ChatPane(props: ChatPaneProps) @{
668
710
  <option value={driver.id}>{driver.label}</option>
669
711
  }
670
712
  </select>
671
- <label class="chat__auto">
672
- <input
673
- type="checkbox"
674
- checked={props.autoContinue}
675
- onInput={(event) => props.onAutoContinue(
676
- event.currentTarget.checked,
677
- )}
678
- />
679
- <span>{t('sandbox.chat.autoHandoff')}</span>
680
- </label>
681
713
  <span class="sandbox__spacer"></span>
682
714
  @if (props.running) {
683
715
  <Button size="sm" variant="danger" onClick={props.onStop}>{t(
@@ -698,7 +730,7 @@ export function ChatPane(props: ChatPaneProps) @{
698
730
  @if (props.running || props.delivered) {
699
731
  <p class="chat__composer-status">
700
732
  {props.running
701
- ? t('sandbox.chat.working')
733
+ ? <TurnActivity lastUpdateAt={props.entries.at(-1)?.at} />
702
734
  : t('sandbox.chat.delivered')}
703
735
  </p>
704
736
  }
@@ -0,0 +1,99 @@
1
+ import { useEffect, useRef, useState } from 'octane';
2
+ import { Icon } from '@flowdular/sdk/ui';
3
+ import { useTranslation } from './i18n.ts';
4
+
5
+ export function ComposerSettings(props: {
6
+ readonly autoContinue: boolean;
7
+ readonly freshContext: boolean;
8
+ readonly running: boolean;
9
+ readonly onAutoContinue: (enabled: boolean) => void;
10
+ readonly onFreshContext: (enabled: boolean) => void;
11
+ }) @{
12
+ const { t } = useTranslation();
13
+ const root = useRef<HTMLDivElement | null>(null);
14
+ const trigger = useRef<HTMLButtonElement | null>(null);
15
+ const [position, setPosition] = useState<
16
+ { left: number; bottom: number } | null
17
+ >(null);
18
+ const toggle = () => {
19
+ if (position) return setPosition(null);
20
+ const rect = trigger.current?.getBoundingClientRect();
21
+ if (!rect) return;
22
+ setPosition({
23
+ left: Math.max(16, Math.min(rect.left, window.innerWidth - 336)),
24
+ bottom: window.innerHeight - rect.top + 8,
25
+ });
26
+ };
27
+ useEffect(() => {
28
+ if (!position) return;
29
+ const dismiss = (event: PointerEvent | FocusEvent) => {
30
+ if (!root.current?.contains(event.target as Node)) setPosition(null);
31
+ };
32
+ const escape = (event: KeyboardEvent) => {
33
+ if (event.key !== 'Escape') return;
34
+ event.preventDefault();
35
+ setPosition(null);
36
+ trigger.current?.focus();
37
+ };
38
+ const resize = () => setPosition(null);
39
+ document.addEventListener('pointerdown', dismiss);
40
+ document.addEventListener('focusin', dismiss);
41
+ document.addEventListener('keydown', escape);
42
+ window.addEventListener('resize', resize);
43
+ return () => {
44
+ document.removeEventListener('pointerdown', dismiss);
45
+ document.removeEventListener('focusin', dismiss);
46
+ document.removeEventListener('keydown', escape);
47
+ window.removeEventListener('resize', resize);
48
+ };
49
+ }, [position]);
50
+
51
+ <div ref={root} class="chat-settings">
52
+ <button
53
+ ref={trigger}
54
+ class="ui-btn ui-btn--ghost ui-btn--sm ui-btn--icon"
55
+ type="button"
56
+ aria-expanded={position !== null}
57
+ aria-controls="composer-settings"
58
+ aria-label={t('sandbox.chat.settings')}
59
+ title={t('sandbox.chat.settings')}
60
+ onClick={toggle}
61
+ >
62
+ <Icon name="settings" size={14} />
63
+ </button>
64
+ @if (position) {
65
+ <div
66
+ id="composer-settings"
67
+ class="ui-menu chat-settings__panel"
68
+ role="group"
69
+ aria-label={t('sandbox.chat.settings')}
70
+ style={{ left: position.left + 'px', bottom: position.bottom + 'px' }}
71
+ >
72
+ <label class="ui-menu__item ui-checkbox">
73
+ <input
74
+ type="checkbox"
75
+ checked={props.autoContinue}
76
+ onChange={(event) => props.onAutoContinue(
77
+ event.currentTarget.checked,
78
+ )}
79
+ />
80
+ <span>{t('sandbox.chat.autoHandoff')}</span>
81
+ </label>
82
+ <label
83
+ class="ui-menu__item ui-checkbox"
84
+ title={t('sandbox.chat.freshHelp')}
85
+ >
86
+ <input
87
+ type="checkbox"
88
+ checked={props.freshContext}
89
+ disabled={props.running}
90
+ onChange={(event) => props.onFreshContext(
91
+ event.currentTarget.checked,
92
+ )}
93
+ />
94
+ <span>{t('sandbox.chat.fresh')}</span>
95
+ </label>
96
+ </div>
97
+ }
98
+ </div>
99
+ }
@@ -0,0 +1,77 @@
1
+ import { Icon } from '@flowdular/sdk/ui';
2
+ import { useTranslation } from './i18n.ts';
3
+ import {
4
+ shortToolDetail,
5
+ toolAction,
6
+ type TranscriptRow,
7
+ } from './transcript.ts';
8
+
9
+ export function ToolEvent(props: {
10
+ readonly row: TranscriptRow;
11
+ readonly running: boolean;
12
+ }) @{
13
+ const { t } = useTranslation();
14
+ const event = props.row.entry.event!;
15
+ const original = props.row.started?.event ?? event;
16
+ if (
17
+ event.type !== 'tool.started' && event.type !== 'tool.completed' ||
18
+ original.type !== 'tool.started' && original.type !== 'tool.completed'
19
+ ) return;
20
+ const status =
21
+ event.type === 'tool.completed'
22
+ ? event.ok
23
+ ? 'done'
24
+ : 'failed'
25
+ : props.row.pending && props.running
26
+ ? 'running'
27
+ : 'unknown';
28
+ const detail = original.detail || event.detail;
29
+ const action = toolAction(original.tool);
30
+ const target =
31
+ action === 'command' ? detail : shortToolDetail(detail);
32
+ const duration =
33
+ props.row.started
34
+ ? (Math.max(0, props.row.entry.at - props.row.started.at) / 1000).toFixed(
35
+ 1,
36
+ )
37
+ : null;
38
+ <details class={['chat-tool', 'chat-tool--' + status]} data-status={status}>
39
+ <summary class="chat-tool__summary">
40
+ <Icon
41
+ name={status === 'running'
42
+ ? 'refresh'
43
+ : status === 'done'
44
+ ? 'check'
45
+ : 'alert'}
46
+ size={14}
47
+ />
48
+ <span class="chat-tool__action">{t(
49
+ 'sandbox.chat.tool.action.' + action,
50
+ )}</span>
51
+ <span class="chat-tool__target" title={detail}>
52
+ {target || original.tool}
53
+ </span>
54
+ <span class="chat-tool__status">{t(
55
+ 'sandbox.chat.tool.status.' + status,
56
+ )}</span>
57
+ @if (duration !== null) {
58
+ <span class="chat-tool__duration">{t('sandbox.chat.tool.duration', {
59
+ seconds: duration,
60
+ })}</span>
61
+ }
62
+ <Icon name="chevron-down" size={14} />
63
+ </summary>
64
+ <dl class="chat-tool__details">
65
+ <dt>{t('sandbox.chat.tool.name')}</dt>
66
+ <dd>{original.tool}</dd>
67
+ <dt>{t('sandbox.chat.tool.detail')}</dt>
68
+ <dd>{detail || t('sandbox.chat.tool.noDetail')}</dd>
69
+ @if (event.detail && event.detail !== detail) {
70
+ <>
71
+ <dt>{t('sandbox.chat.tool.result')}</dt>
72
+ <dd>{event.detail}</dd>
73
+ </>
74
+ }
75
+ </dl>
76
+ </details>
77
+ }
@@ -0,0 +1,26 @@
1
+ import { useEffect, useState } from 'octane';
2
+ import { useTranslation } from './i18n.ts';
3
+
4
+ /* Mounted only while a turn runs. Age measures received updates, not provider
5
+ health: a quiet process can still be generating or waiting on the network. */
6
+ export function TurnActivity(props: {
7
+ readonly lastUpdateAt: number | undefined;
8
+ }) @{
9
+ const { t } = useTranslation();
10
+ const [startedAt] = useState(Date.now());
11
+ const [now, setNow] = useState(Date.now());
12
+ useEffect(() => {
13
+ const timer = setInterval(() => setNow(Date.now()), 1000);
14
+ return () => clearInterval(timer);
15
+ }, []);
16
+ const seconds = Math.max(
17
+ 0,
18
+ Math.floor((now - (props.lastUpdateAt ?? startedAt)) / 1000),
19
+ );
20
+ <span class="chat__activity-age">{t(
21
+ seconds >= 30
22
+ ? 'sandbox.chat.activity.quiet'
23
+ : 'sandbox.chat.activity.updated',
24
+ { seconds },
25
+ )}</span>
26
+ }
@@ -257,6 +257,7 @@
257
257
  "chat.role": "Role",
258
258
  "chat.targetModule": "Target module",
259
259
  "chat.auto": "Auto",
260
+ "chat.settings": "Settings",
260
261
  "chat.autoHandoff": "Continue handoffs automatically",
261
262
  "chat.stop": "Stop",
262
263
  "chat.send": "Send",
@@ -480,8 +481,27 @@
480
481
  "models.save": "Save",
481
482
  "models.saved": "Model settings saved.",
482
483
  "models.error": "Could not save model settings.",
484
+ "chat.activity.updated": "Last update {seconds}s ago",
485
+ "chat.activity.quiet": "No update for {seconds}s. The agent may still be working.",
483
486
  "chat.activity.thinking": "Agent is thinking…",
484
487
  "chat.activity.responding": "Agent is preparing a response…",
485
488
  "chat.fresh": "Fresh agent context",
486
- "chat.freshHelp": "The next message starts a fresh context with the brief and recent messages. Draft files, specification and sandbox history are preserved."
489
+ "chat.freshHelp": "The next message starts a fresh context with the brief and recent messages. Draft files, specification and sandbox history are preserved.",
490
+ "chat.tool.name": "Tool",
491
+ "chat.tool.detail": "Target or command",
492
+ "chat.tool.result": "Completion detail",
493
+ "chat.tool.noDetail": "No detail provided",
494
+ "chat.tool.duration": "{seconds}s",
495
+ "chat.tool.action.read": "Read file",
496
+ "chat.tool.action.edit": "Edit file",
497
+ "chat.tool.action.write": "Write file",
498
+ "chat.tool.action.delete": "Delete file",
499
+ "chat.tool.action.list": "Find files",
500
+ "chat.tool.action.search": "Search",
501
+ "chat.tool.action.command": "Run command",
502
+ "chat.tool.action.tool": "Tool call",
503
+ "chat.tool.status.done": "Done",
504
+ "chat.tool.status.failed": "Failed",
505
+ "chat.tool.status.running": "Running",
506
+ "chat.tool.status.unknown": "No result"
487
507
  }
@@ -257,6 +257,7 @@
257
257
  "chat.role": "Rola",
258
258
  "chat.targetModule": "Moduł docelowy",
259
259
  "chat.auto": "Automatycznie",
260
+ "chat.settings": "Ustawienia",
260
261
  "chat.autoHandoff": "Automatycznie kontynuuj przekazania",
261
262
  "chat.stop": "Zatrzymaj",
262
263
  "chat.send": "Wyślij",
@@ -480,8 +481,27 @@
480
481
  "models.save": "Zapisz",
481
482
  "models.saved": "Zapisano ustawienia modeli.",
482
483
  "models.error": "Nie udało się zapisać ustawień modeli.",
484
+ "chat.activity.updated": "Ostatnia aktualizacja {seconds}s temu",
485
+ "chat.activity.quiet": "Brak aktualizacji od {seconds}s. Agent może nadal pracować.",
483
486
  "chat.activity.thinking": "Agent analizuje zadanie…",
484
487
  "chat.activity.responding": "Agent przygotowuje odpowiedź…",
485
488
  "chat.fresh": "Świeży kontekst agenta",
486
- "chat.freshHelp": "Następna wiadomość rozpocznie nowy kontekst z briefem i ostatnimi wiadomościami. Pliki, specyfikacja i historia sandboxa pozostaną zachowane."
489
+ "chat.freshHelp": "Następna wiadomość rozpocznie nowy kontekst z briefem i ostatnimi wiadomościami. Pliki, specyfikacja i historia sandboxa pozostaną zachowane.",
490
+ "chat.tool.name": "Narzędzie",
491
+ "chat.tool.detail": "Plik lub polecenie",
492
+ "chat.tool.result": "Szczegóły zakończenia",
493
+ "chat.tool.noDetail": "Brak szczegółów",
494
+ "chat.tool.duration": "{seconds}s",
495
+ "chat.tool.action.read": "Odczyt pliku",
496
+ "chat.tool.action.edit": "Edycja pliku",
497
+ "chat.tool.action.write": "Zapis pliku",
498
+ "chat.tool.action.delete": "Usunięcie pliku",
499
+ "chat.tool.action.list": "Wyszukiwanie plików",
500
+ "chat.tool.action.search": "Wyszukiwanie",
501
+ "chat.tool.action.command": "Polecenie",
502
+ "chat.tool.action.tool": "Narzędzie",
503
+ "chat.tool.status.done": "Gotowe",
504
+ "chat.tool.status.failed": "Błąd",
505
+ "chat.tool.status.running": "W toku",
506
+ "chat.tool.status.unknown": "Brak wyniku"
487
507
  }
@@ -0,0 +1,110 @@
1
+ import type { ChatEntry } from '../server/sessions.ts';
2
+
3
+ export interface TranscriptRow {
4
+ readonly key: number;
5
+ entry: ChatEntry;
6
+ started?: ChatEntry;
7
+ pending: boolean;
8
+ }
9
+
10
+ /* Pair calls by provider identity, including parallel calls to the same file.
11
+ Old transcripts without ids are paired only with one unambiguous pending call.
12
+ This is presentation only: persisted events and their full details stay intact. */
13
+ export function transcriptRows(entries: readonly ChatEntry[]): TranscriptRow[] {
14
+ const rows: TranscriptRow[] = [];
15
+ const calls = new Map<string, TranscriptRow>();
16
+ const pending = new Set<TranscriptRow>();
17
+ const closeTurn = () => {
18
+ for (const row of pending) row.pending = false;
19
+ pending.clear();
20
+ calls.clear();
21
+ };
22
+ for (const entry of entries) {
23
+ const event = entry.event;
24
+ if (
25
+ entry.kind === 'user' ||
26
+ entry.handoff ||
27
+ event?.type === 'turn.started' ||
28
+ event?.type === 'turn.completed'
29
+ )
30
+ closeTurn();
31
+ if (event?.type === 'reasoning' && !event.text.trim()) continue;
32
+ if (event?.type === 'tool.completed') {
33
+ let start = event.callId ? calls.get(event.callId) : undefined;
34
+ if (!event.callId && pending.size === 1) {
35
+ const candidate = pending.values().next().value!;
36
+ const original = candidate.entry.event;
37
+ if (
38
+ original?.type === 'tool.started' &&
39
+ !original.callId &&
40
+ (event.tool === original.tool || event.tool === 'tool') &&
41
+ (!event.detail || event.detail === original.detail)
42
+ )
43
+ start = candidate;
44
+ }
45
+ if (
46
+ start &&
47
+ start.entry.role === entry.role &&
48
+ start.entry.module === entry.module
49
+ ) {
50
+ start.started = start.entry;
51
+ start.entry = entry;
52
+ start.pending = false;
53
+ pending.delete(start);
54
+ if (event.callId) calls.delete(event.callId);
55
+ continue;
56
+ }
57
+ }
58
+ const row: TranscriptRow = {
59
+ key: entry.sequence,
60
+ entry,
61
+ pending: event?.type === 'tool.started',
62
+ };
63
+ rows.push(row);
64
+ if (event?.type === 'tool.started') {
65
+ pending.add(row);
66
+ if (event.callId) calls.set(event.callId, row);
67
+ }
68
+ }
69
+ return rows;
70
+ }
71
+
72
+ export function shortToolDetail(detail: string): string {
73
+ if (!/^(?:\/|[A-Za-z]:[\\/]|modules[\\/])/.test(detail)) return detail;
74
+ return detail
75
+ .replaceAll('\\', '/')
76
+ .replace(
77
+ /^.*?\/(?:\.flowdular|\.coreloom)\/sandbox\/sessions\/[^/]+\/workspace\//,
78
+ '',
79
+ )
80
+ .replace(/^modules\//, '');
81
+ }
82
+
83
+ export function toolAction(name: string): string {
84
+ switch (name.toLowerCase()) {
85
+ case 'read':
86
+ case 'read_file':
87
+ return 'read';
88
+ case 'edit':
89
+ case 'multiedit':
90
+ case 'update':
91
+ return 'edit';
92
+ case 'write':
93
+ case 'write_file':
94
+ case 'create':
95
+ return 'write';
96
+ case 'delete_file':
97
+ case 'delete':
98
+ return 'delete';
99
+ case 'glob':
100
+ case 'list_files':
101
+ return 'list';
102
+ case 'grep':
103
+ return 'search';
104
+ case 'bash':
105
+ case 'command':
106
+ return 'command';
107
+ default:
108
+ return 'tool';
109
+ }
110
+ }
@@ -9,6 +9,7 @@ import {
9
9
  writeFile,
10
10
  } from 'node:fs/promises';
11
11
  import { basename, join } from 'node:path';
12
+ import { referenceSource } from './reference.ts';
12
13
  import {
13
14
  basePathOf,
14
15
  modulePathOf,
@@ -107,7 +108,7 @@ export async function prepareAutoReview(
107
108
  const skill = join(paths.workspace, 'reference', 'skills', 'auto-review');
108
109
  await mkdir(skill, { recursive: true });
109
110
  await cp(
110
- join(workspaceRoot, '.ai', 'skills', 'auto-review', 'SKILL.md'),
111
+ await referenceSource(workspaceRoot, '.ai/skills/auto-review/SKILL.md'),
111
112
  join(skill, 'SKILL.md'),
112
113
  );
113
114
  }
@@ -0,0 +1,88 @@
1
+ import { matchesGlob } from 'node:path';
2
+ import type { AgentRoleDefinition } from '#coding-agent';
3
+ import type { GateResult } from './gates.ts';
4
+
5
+ interface DiagnosticPath {
6
+ path: string;
7
+ module: string;
8
+ }
9
+
10
+ /* Read diagnostic locations, never instructions embedded in output. Manifest
11
+ reports name the module; their issues name the file that actually failed. */
12
+ function locations(gate: GateResult, activeModule: string): DiagnosticPath[] {
13
+ const module = gate.module ?? activeModule;
14
+ const output = gate.output.slice(0, 16_000);
15
+ try {
16
+ const result = JSON.parse(
17
+ output.slice(output.indexOf('{'), output.lastIndexOf('}') + 1),
18
+ );
19
+ const reports = result?.error?.details?.reports;
20
+ if (Array.isArray(reports)) {
21
+ return reports.flatMap((report) => {
22
+ const target =
23
+ typeof report.file === 'string'
24
+ ? (/(?:^|\/)modules\/([a-z0-9-]+)\/module\.json$/.exec(
25
+ report.file,
26
+ )?.[1] ?? module)
27
+ : module;
28
+ return Array.isArray(report.issues)
29
+ ? report.issues
30
+ .filter(
31
+ (issue: { severity?: string; path?: unknown }) =>
32
+ issue.severity === 'error' && typeof issue.path === 'string',
33
+ )
34
+ .map((issue: { path: string }) => ({
35
+ path: issue.path,
36
+ module: target,
37
+ }))
38
+ : [];
39
+ });
40
+ }
41
+ } catch {
42
+ /* Compiler and formatter output is plain text. */
43
+ }
44
+ return output.split('\n').flatMap((line) => {
45
+ const match =
46
+ /^\s*(?:FAIL\s+)?((?:modules\/[a-z0-9-]+\/)?(?:src|tests|translations|migrations)\/[^\s:(]+)(?:\(\d+,\d+\)|:\d+|\s|$)/.exec(
47
+ line,
48
+ );
49
+ if (!match) return [];
50
+ const qualified = /^modules\/([a-z0-9-]+)\/(.+)$/.exec(match[1]!);
51
+ return [
52
+ { path: qualified?.[2] ?? match[1]!, module: qualified?.[1] ?? module },
53
+ ];
54
+ });
55
+ }
56
+
57
+ export function gateRepairOwner(
58
+ gate: GateResult,
59
+ activeModule: string,
60
+ roles: readonly AgentRoleDefinition[],
61
+ modules: readonly string[],
62
+ ): { role: string; module: string } | null {
63
+ for (const location of locations(gate, activeModule)) {
64
+ if (
65
+ !modules.includes(location.module) ||
66
+ location.path.split('/').includes('..')
67
+ )
68
+ continue;
69
+ const preferred =
70
+ location.path.startsWith('src/client/') ||
71
+ location.path.startsWith('translations/')
72
+ ? 'frontend-engineer'
73
+ : location.path.startsWith('spec/')
74
+ ? 'business-manager'
75
+ : location.path.startsWith('src/agent/') ||
76
+ location.path.startsWith('src/tools/')
77
+ ? 'agentic-engineer'
78
+ : 'backend-engineer';
79
+ const candidates = roles.filter((role) =>
80
+ role.allowedPaths.some((pattern) => matchesGlob(location.path, pattern)),
81
+ );
82
+ const owner =
83
+ candidates.find((role) => role.id === preferred) ??
84
+ (candidates.length === 1 ? candidates[0] : undefined);
85
+ if (owner) return { role: owner.id, module: location.module };
86
+ }
87
+ return null;
88
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  HandoffDeclaration,
9
9
  } from '#coding-agent';
10
10
  import type { GateResult } from './gates.ts';
11
+ import { gateRepairOwner } from './gate-repair.ts';
11
12
  import { SandboxSetupError } from './workspace-root.ts';
12
13
  import {
13
14
  moduleSuffixOf,
@@ -573,20 +574,29 @@ export function planHandoff(context: HandoffContext): HandoffPlan {
573
574
  there even when the finished turn worked somewhere else. */
574
575
  const failedGate = context.gates.find((gate) => gate.status !== 'passed');
575
576
  if (failedGate) {
576
- const repairRole = context.reviewing
577
- ? (validateDeclared(context).role ?? context.role)
578
- : context.role;
577
+ const owner = gateRepairOwner(
578
+ failedGate,
579
+ context.module,
580
+ roles,
581
+ context.routing.session.modules.map((module) => module.directory),
582
+ );
583
+ const repairRole =
584
+ owner?.role ??
585
+ (context.reviewing
586
+ ? (validateDeclared(context).role ?? context.role)
587
+ : context.role);
579
588
  return plan(
580
589
  'continue',
581
590
  repairRole,
582
591
  `The ${gateLabel(failedGate)} gate failed, so the responsible specialist fixes it before delivery.`,
583
592
  [
584
- `The ${gateLabel(failedGate)} gate failed after your change. Fix exactly what it reports, change nothing else, and end with your handoff line.`,
593
+ `Continue as ${roleName(roles, repairRole)}. The ${gateLabel(failedGate)} gate failed. Fix the reported files within your role, preserve other work, and end with your handoff line.`,
594
+ `Recorded gate results:\n${context.gates.map((gate) => `${gateLabel(gate)}: ${gate.status}`).join('\n')}`,
585
595
  `Gate command: ${failedGate.command}`,
586
596
  `Gate output (first ${GATE_PROMPT_OUTPUT} characters; the transcript holds the rest):`,
587
597
  failedGate.output.slice(0, GATE_PROMPT_OUTPUT),
588
598
  ].join('\n\n'),
589
- failedGate.module ?? context.module,
599
+ owner?.module ?? failedGate.module ?? context.module,
590
600
  );
591
601
  }
592
602
 
@@ -0,0 +1,40 @@
1
+ import { relative } from 'node:path';
2
+ import type { Plugin } from 'vite';
3
+
4
+ /* Octane broadcasts full-reload for every TSRX change, including modules used
5
+ only by preview iframes. Draft saves must invalidate cached transforms without
6
+ navigating the operator's page. The preview refreshes at turn completion or
7
+ on request, when its isolated worker also selects the current revision. */
8
+ export function isolatePreviewHotUpdates(
9
+ plugins: Plugin[],
10
+ workspaceRoot: string,
11
+ ): Plugin[] {
12
+ return plugins.map((plugin) => {
13
+ const hook = plugin.hotUpdate;
14
+ if (!hook) return plugin;
15
+ const original = typeof hook === 'function' ? hook : hook.handler;
16
+ return {
17
+ ...plugin,
18
+ hotUpdate: {
19
+ ...(typeof hook === 'function' ? {} : hook),
20
+ async handler(options) {
21
+ const path = relative(workspaceRoot, options.file).replaceAll(
22
+ '\\',
23
+ '/',
24
+ );
25
+ if (/^(?:\.flowdular|\.coreloom)\/sandbox\/sessions\//.test(path)) {
26
+ for (const environment of Object.values(
27
+ options.server.environments,
28
+ )) {
29
+ const graph = environment.moduleGraph;
30
+ for (const module of graph.getModulesByFile(options.file) ?? [])
31
+ graph.invalidateModule(module);
32
+ }
33
+ return [];
34
+ }
35
+ return original.call(this, options);
36
+ },
37
+ },
38
+ };
39
+ });
40
+ }
@@ -9,7 +9,7 @@ import {
9
9
  } from 'node:fs/promises';
10
10
  import { dirname, join, relative } from 'node:path';
11
11
 
12
- async function referenceSource(
12
+ export async function referenceSource(
13
13
  workspaceRoot: string,
14
14
  path: string,
15
15
  ): Promise<string> {
package/src/styles.css CHANGED
@@ -683,13 +683,16 @@
683
683
  color: var(--danger);
684
684
  }
685
685
 
686
- .chat__auto {
686
+ .chat-settings {
687
687
  display: inline-flex;
688
- align-items: center;
689
- gap: 6px;
690
- font-size: var(--text-sm);
691
- color: var(--ink-3);
692
- white-space: nowrap;
688
+ }
689
+
690
+ .chat-settings__panel {
691
+ position: fixed;
692
+ z-index: 20;
693
+ width: min(320px, calc(100vw - 32px));
694
+ max-height: calc(100dvh - 32px);
695
+ overflow-y: auto;
693
696
  }
694
697
 
695
698
  .chat__selection {
@@ -1571,3 +1574,102 @@
1571
1574
  font-size: var(--text-md);
1572
1575
  color: var(--ink-3);
1573
1576
  }
1577
+
1578
+ @keyframes chat-activity-spin {
1579
+ to {
1580
+ transform: rotate(360deg);
1581
+ }
1582
+ }
1583
+ .chat__event--active > svg {
1584
+ animation: chat-activity-spin 1.5s linear infinite;
1585
+ }
1586
+ @media (prefers-reduced-motion: reduce) {
1587
+ .chat__event--active > svg {
1588
+ animation: none;
1589
+ }
1590
+ }
1591
+
1592
+ /* Compact operation summaries with the original details available on demand. */
1593
+ .chat-tool {
1594
+ min-width: 0;
1595
+ color: var(--ink-2);
1596
+ font-size: var(--text-sm);
1597
+ }
1598
+ .chat-tool__summary {
1599
+ display: flex;
1600
+ align-items: center;
1601
+ gap: 8px;
1602
+ min-width: 0;
1603
+ padding: 6px 0;
1604
+ cursor: pointer;
1605
+ list-style: none;
1606
+ }
1607
+ .chat-tool__summary::-webkit-details-marker {
1608
+ display: none;
1609
+ }
1610
+ .chat-tool__summary:focus-visible {
1611
+ outline: 2px solid var(--focus);
1612
+ outline-offset: 2px;
1613
+ }
1614
+ .chat-tool__summary > svg {
1615
+ flex-shrink: 0;
1616
+ }
1617
+ .chat-tool__action {
1618
+ flex-shrink: 0;
1619
+ }
1620
+ .chat-tool__target {
1621
+ min-width: 0;
1622
+ flex: 1;
1623
+ overflow: hidden;
1624
+ text-overflow: ellipsis;
1625
+ white-space: nowrap;
1626
+ font-family: var(--font-mono);
1627
+ }
1628
+ .chat-tool__status,
1629
+ .chat-tool__duration {
1630
+ color: var(--ink-3);
1631
+ flex-shrink: 0;
1632
+ font-variant-numeric: tabular-nums;
1633
+ }
1634
+ .chat-tool--failed .chat-tool__status,
1635
+ .chat-tool--failed .chat-tool__summary > svg:first-child {
1636
+ color: var(--danger);
1637
+ }
1638
+ .chat-tool[open] .chat-tool__summary > svg:last-child {
1639
+ transform: rotate(180deg);
1640
+ }
1641
+ .chat-tool__details {
1642
+ margin: 0 0 8px 22px;
1643
+ padding: 12px;
1644
+ border: 1px solid var(--line);
1645
+ border-radius: var(--r-sm);
1646
+ }
1647
+ .chat-tool__details dt {
1648
+ color: var(--ink-3);
1649
+ margin-bottom: 4px;
1650
+ }
1651
+ .chat-tool__details dd {
1652
+ margin: 0 0 8px;
1653
+ white-space: pre-wrap;
1654
+ overflow-wrap: anywhere;
1655
+ font-family: var(--font-mono);
1656
+ }
1657
+ .chat-tool__details dd:last-child {
1658
+ margin-bottom: 0;
1659
+ }
1660
+ .chat-tool--running .chat-tool__summary > svg:first-child {
1661
+ animation: chat-activity-spin 1.5s linear infinite;
1662
+ }
1663
+ @media (prefers-reduced-motion: reduce) {
1664
+ .chat-tool--running .chat-tool__summary > svg:first-child {
1665
+ animation: none;
1666
+ }
1667
+ }
1668
+ @media (max-width: 600px) {
1669
+ .chat-tool__summary {
1670
+ flex-wrap: wrap;
1671
+ }
1672
+ .chat-tool__target {
1673
+ flex-basis: calc(100% - 32px);
1674
+ }
1675
+ }
package/vite.config.ts CHANGED
@@ -9,6 +9,7 @@ import { defineConfig } from 'vite';
9
9
  import { sandboxDirectory } from './src/server/config.ts';
10
10
  import { findFlowdularWorkspace } from './src/server/workspace-root.ts';
11
11
  import { resolvePreviewModules } from './src/server/preview-modules.ts';
12
+ import { isolatePreviewHotUpdates } from './src/server/preview-hot-updates.ts';
12
13
  import type { SandboxSession } from './src/server/sessions.ts';
13
14
 
14
15
  Object.assign(process.env, flowdularEnvironment(process.env));
@@ -103,7 +104,10 @@ function previewModules(workspaceRoot: string): Plugin {
103
104
 
104
105
  const config = {
105
106
  root: appRoot,
106
- plugins: [previewModules(workspace.root), octane()],
107
+ plugins: [
108
+ previewModules(workspace.root),
109
+ ...isolatePreviewHotUpdates(octane(), workspace.root),
110
+ ],
107
111
  resolve: {
108
112
  /* Draft module code is loaded from a session workspace outside this app.
109
113
  It resolves the workspace packages through the node_modules link the