@flowdular/sandbox 0.2.6 → 0.2.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.
- package/internal/coding-agent/src/drivers/byok.ts +17 -3
- package/internal/coding-agent/src/drivers/claude-code.ts +36 -8
- package/internal/coding-agent/src/drivers/codex.ts +8 -0
- package/internal/coding-agent/src/types.ts +2 -0
- package/internal/coding-agent/src/workspace.ts +21 -5
- package/package.json +1 -1
- package/src/client/ChatPane.tsrx +77 -45
- package/src/client/ComposerSettings.tsrx +99 -0
- package/src/client/ToolEvent.tsrx +77 -0
- package/src/client/TurnActivity.tsrx +26 -0
- package/src/client/locales/en.json +21 -1
- package/src/client/locales/pl.json +21 -1
- package/src/client/transcript.ts +110 -0
- package/src/server/attachments.ts +67 -30
- package/src/server/auto-review.ts +2 -1
- package/src/server/gate-repair.ts +88 -0
- package/src/server/planning.ts +15 -5
- package/src/server/preview-hot-updates.ts +40 -0
- package/src/server/reference.ts +10 -1
- package/src/server/routes.ts +2 -3
- package/src/server/sdk-reference.ts +99 -0
- package/src/server/session-lock.ts +26 -0
- package/src/server/sessions.ts +14 -6
- package/src/server/turns.ts +20 -2
- package/src/styles.css +108 -6
- package/vite.config.ts +5 -1
|
@@ -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
|
-
|
|
94
|
+
const callId = String(++nextCall);
|
|
95
|
+
emit({ type: 'tool.started', callId, tool: name, detail });
|
|
94
96
|
return run().then(
|
|
95
97
|
(value) => {
|
|
96
|
-
emit({
|
|
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({
|
|
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')
|
|
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
|
-
|
|
205
|
-
(
|
|
225
|
+
phase &&
|
|
226
|
+
(partial?.type === 'content_block_start' ||
|
|
227
|
+
now - lastActivityAt >= 10_000)
|
|
206
228
|
) {
|
|
207
|
-
|
|
208
|
-
|
|
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' &&
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.8",
|
|
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",
|
package/src/client/ChatPane.tsrx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
132
|
+
events: TranscriptRow[] | null;
|
|
132
133
|
}[] = [];
|
|
133
|
-
for (const
|
|
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
|
-
|
|
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: [
|
|
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
|
|
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
|
-
/*
|
|
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
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
'
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
<
|
|
409
|
-
|
|
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
|
-
?
|
|
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
|
+
}
|