Skip to content

Commit af1096b

Browse files
committed
WIP
Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 57e833f commit af1096b

File tree

5 files changed

+148
-34
lines changed

5 files changed

+148
-34
lines changed

src/browser/components/AIView.tsx

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -314,10 +314,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
314314
}
315315

316316
// Extract state from workspace state
317-
const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState;
317+
const { messages, canInterrupt, isCompacting, loading, currentModel, pendingScriptExecution } =
318+
workspaceState;
318319

319320
// Get active stream message ID for token counting
320321
const activeStreamMessageId = aggregator.getActiveStreamMessageId();
322+
const isScriptExecutionPending = Boolean(pendingScriptExecution);
321323

322324
// Note: We intentionally do NOT reset autoRetry when streams start.
323325
// If user pressed the interrupt key, autoRetry stays false until they manually retry.
@@ -377,6 +379,33 @@ const AIViewInner: React.FC<AIViewProps> = ({
377379
);
378380
}
379381

382+
const interruptKeybindDisplay = formatKeybind(
383+
vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL
384+
);
385+
const acceptEarlyKeybind = formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION);
386+
const streamingStatusText = pendingScriptExecution
387+
? `${pendingScriptExecution.command} running...`
388+
: isCompacting
389+
? currentModel
390+
? `${getModelName(currentModel)} compacting...`
391+
: "compacting..."
392+
: currentModel
393+
? `${getModelName(currentModel)} streaming...`
394+
: "streaming...";
395+
const streamingCancelText = pendingScriptExecution
396+
? `hit ${interruptKeybindDisplay} to cancel script`
397+
: isCompacting
398+
? `${interruptKeybindDisplay} cancel | ${acceptEarlyKeybind} accept early`
399+
: `hit ${interruptKeybindDisplay} to cancel`;
400+
const streamingTokenCount =
401+
isScriptExecutionPending || !activeStreamMessageId
402+
? undefined
403+
: aggregator.getStreamingTokenCount(activeStreamMessageId);
404+
const streamingTPS =
405+
isScriptExecutionPending || !activeStreamMessageId
406+
? undefined
407+
: aggregator.getStreamingTPS(activeStreamMessageId);
408+
380409
return (
381410
<div
382411
className={cn(
@@ -469,30 +498,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
469498
<PinnedTodoList workspaceId={workspaceId} />
470499
{canInterrupt && (
471500
<StreamingBarrier
472-
statusText={
473-
isCompacting
474-
? currentModel
475-
? `${getModelName(currentModel)} compacting...`
476-
: "compacting..."
477-
: currentModel
478-
? `${getModelName(currentModel)} streaming...`
479-
: "streaming..."
480-
}
481-
cancelText={
482-
isCompacting
483-
? `${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} cancel | ${formatKeybind(KEYBINDS.ACCEPT_EARLY_COMPACTION)} accept early`
484-
: `hit ${formatKeybind(vimEnabled ? KEYBINDS.INTERRUPT_STREAM_VIM : KEYBINDS.INTERRUPT_STREAM_NORMAL)} to cancel`
485-
}
486-
tokenCount={
487-
activeStreamMessageId
488-
? aggregator.getStreamingTokenCount(activeStreamMessageId)
489-
: undefined
490-
}
491-
tps={
492-
activeStreamMessageId
493-
? aggregator.getStreamingTPS(activeStreamMessageId)
494-
: undefined
495-
}
501+
statusText={streamingStatusText}
502+
cancelText={streamingCancelText}
503+
tokenCount={streamingTokenCount}
504+
tps={streamingTPS}
496505
/>
497506
)}
498507
{workspaceState?.queuedMessage && (

src/browser/stores/WorkspaceStore.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
2+
import { createMuxMessage } from "@/common/types/message";
3+
import type { WorkspaceChatMessage } from "@/common/types/ipc";
4+
import type { BashToolResult } from "@/common/types/tools";
25
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
36
import { WorkspaceStore } from "./WorkspaceStore";
47

@@ -13,6 +16,13 @@ const mockExecuteBash = jest.fn(() => ({
1316
},
1417
}));
1518

19+
const SCRIPT_RESULT: BashToolResult = {
20+
success: true,
21+
output: "ok",
22+
exitCode: 0,
23+
wall_duration_ms: 1,
24+
};
25+
1626
const mockWindow = {
1727
api: {
1828
workspace: {
@@ -246,6 +256,55 @@ describe("WorkspaceStore", () => {
246256
});
247257
});
248258

259+
describe("script execution state", () => {
260+
it("treats pending scripts as interruptible", async () => {
261+
const workspaceId = "script-workspace";
262+
createAndAddWorkspace(store, workspaceId);
263+
264+
const onChatCallback = getOnChatCallback<WorkspaceChatMessage>();
265+
266+
onChatCallback({ type: "caught-up" });
267+
268+
const timestamp = Date.now();
269+
const baseMetadata = {
270+
historySequence: 1,
271+
timestamp,
272+
muxMetadata: {
273+
type: "script-execution" as const,
274+
id: "script-exec-1",
275+
historySequence: 1,
276+
timestamp,
277+
command: "/script wait_pr_checks",
278+
scriptName: "wait_pr_checks",
279+
args: [] as string[],
280+
},
281+
};
282+
283+
const scriptMessage = createMuxMessage("script-1", "user", "Run script", baseMetadata);
284+
onChatCallback(scriptMessage);
285+
await new Promise((resolve) => setTimeout(resolve, 0));
286+
287+
const pendingState = store.getWorkspaceState(workspaceId);
288+
expect(pendingState.canInterrupt).toBe(true);
289+
expect(pendingState.pendingScriptExecution).toMatchObject({
290+
scriptName: "wait_pr_checks",
291+
});
292+
293+
const completedScript = createMuxMessage("script-1", "user", "Run script", {
294+
...baseMetadata,
295+
muxMetadata: {
296+
...baseMetadata.muxMetadata,
297+
result: SCRIPT_RESULT,
298+
},
299+
});
300+
onChatCallback(completedScript);
301+
await new Promise((resolve) => setTimeout(resolve, 0));
302+
303+
const finalState = store.getWorkspaceState(workspaceId);
304+
expect(finalState.pendingScriptExecution).toBeNull();
305+
expect(finalState.canInterrupt).toBe(false);
306+
});
307+
});
249308
describe("getWorkspaceState", () => {
250309
it("should return initial state for newly added workspace", () => {
251310
createAndAddWorkspace(store, "new-workspace");

src/browser/stores/WorkspaceStore.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { createMuxMessage } from "@/common/types/message";
99
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
1010
import type { WorkspaceChatMessage } from "@/common/types/ipc";
1111
import type { TodoItem } from "@/common/types/tools";
12-
import { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
12+
import {
13+
StreamingMessageAggregator,
14+
type PendingScriptExecutionInfo,
15+
} from "@/browser/utils/messages/StreamingMessageAggregator";
1316
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
1417
import { getRetryStateKey } from "@/common/constants/storage";
1518
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
@@ -48,6 +51,7 @@ export interface WorkspaceState {
4851
recencyTimestamp: number | null;
4952
todos: TodoItem[];
5053
agentStatus: { emoji: string; message: string; url?: string } | undefined;
54+
pendingScriptExecution: PendingScriptExecutionInfo | null;
5155
pendingStreamStartTime: number | null;
5256
}
5357

@@ -340,8 +344,9 @@ export class WorkspaceStore {
340344
const activeStreams = aggregator.getActiveStreams();
341345
const messages = aggregator.getAllMessages();
342346
const metadata = this.workspaceMetadata.get(workspaceId);
347+
const pendingScriptExecution = aggregator.getPendingScriptExecution();
343348

344-
const canInterrupt = activeStreams.length > 0 || aggregator.hasPendingScriptExecution();
349+
const canInterrupt = activeStreams.length > 0 || pendingScriptExecution !== null;
345350

346351
return {
347352
name: metadata?.name ?? workspaceId, // Fall back to ID if metadata missing
@@ -356,6 +361,7 @@ export class WorkspaceStore {
356361
todos: aggregator.getCurrentTodos(),
357362
agentStatus: aggregator.getAgentStatus(),
358363
pendingStreamStartTime: aggregator.getPendingStreamStartTime(),
364+
pendingScriptExecution,
359365
};
360366
});
361367
}

src/browser/utils/messages/StreamingMessageAggregator.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,10 @@ describe("StreamingMessageAggregator", () => {
442442

443443
aggregator.addMessage(pendingScript);
444444
expect(aggregator.hasPendingScriptExecution()).toBe(true);
445+
expect(aggregator.getPendingScriptExecution()).toMatchObject({
446+
scriptName: "wait",
447+
command: "/script wait",
448+
});
445449

446450
const completedScript = createMuxMessage("script-2", "user", "Run script", {
447451
historySequence: 1,
@@ -460,6 +464,7 @@ describe("StreamingMessageAggregator", () => {
460464

461465
aggregator.addMessage(completedScript);
462466
expect(aggregator.hasPendingScriptExecution()).toBe(false);
467+
expect(aggregator.getPendingScriptExecution()).toBeNull();
463468
});
464469

465470
test("clears pending script executions when messages are deleted", () => {
@@ -482,11 +487,16 @@ describe("StreamingMessageAggregator", () => {
482487

483488
aggregator.addMessage(pendingScript);
484489
expect(aggregator.hasPendingScriptExecution()).toBe(true);
490+
expect(aggregator.getPendingScriptExecution()).toMatchObject({
491+
scriptName: "cleanup",
492+
command: "/script cleanup",
493+
});
485494

486495
const deleteEvent: DeleteMessage = { type: "delete", historySequences: [7] };
487496
aggregator.handleDeleteMessage(deleteEvent);
488497

489498
expect(aggregator.hasPendingScriptExecution()).toBe(false);
499+
expect(aggregator.getPendingScriptExecution()).toBeNull();
490500
});
491501
test("removes script logs when history is truncated", () => {
492502
const aggregator = new StreamingMessageAggregator(TEST_CREATED_AT);

src/browser/utils/messages/StreamingMessageAggregator.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ import { computeRecencyTimestamp } from "./recency";
3333
// Full history is still maintained internally for token counting and stats
3434
const MAX_DISPLAYED_MESSAGES = 128;
3535

36+
export interface PendingScriptExecutionInfo {
37+
messageId: string;
38+
command: string;
39+
scriptName: string;
40+
args: string[];
41+
timestamp: number;
42+
}
3643
interface StreamingContext {
3744
startTime: number;
3845
isComplete: boolean;
@@ -100,7 +107,7 @@ export class StreamingMessageAggregator {
100107
// (or the user retries) so retry UI/backoff logic doesn't misfire on send failures.
101108

102109
private pendingStreamStartTime: number | null = null;
103-
private pendingScriptExecutions = new Set<string>();
110+
private pendingScriptExecutions = new Map<string, PendingScriptExecutionInfo>();
104111

105112
// Workspace creation timestamp (used for recency calculation)
106113
// REQUIRED: Backend guarantees every workspace has createdAt via config.ts
@@ -277,26 +284,49 @@ export class StreamingMessageAggregator {
277284
return this.pendingStreamStartTime;
278285
}
279286

280-
private setPendingStreamStartTime(time: number | null): void {
281-
this.pendingStreamStartTime = time;
282-
}
283287
hasPendingScriptExecution(): boolean {
284288
return this.pendingScriptExecutions.size > 0;
285289
}
286290

291+
getPendingScriptExecution(): PendingScriptExecutionInfo | null {
292+
if (this.pendingScriptExecutions.size === 0) {
293+
return null;
294+
}
295+
296+
let latest: PendingScriptExecutionInfo | null = null;
297+
for (const info of this.pendingScriptExecutions.values()) {
298+
if (!latest || info.timestamp > latest.timestamp) {
299+
latest = info;
300+
}
301+
}
302+
return latest;
303+
}
304+
287305
private syncScriptExecutionState(message: MuxMessage): void {
288306
const muxMetadata = message.metadata?.muxMetadata;
289307
if (muxMetadata?.type === "script-execution" && muxMetadata.result === undefined) {
290-
this.pendingScriptExecutions.add(message.id);
291-
} else {
292-
this.pendingScriptExecutions.delete(message.id);
308+
const info: PendingScriptExecutionInfo = {
309+
messageId: message.id,
310+
command: muxMetadata.command ?? `/script ${muxMetadata.scriptName}`,
311+
scriptName: muxMetadata.scriptName,
312+
args: Array.isArray(muxMetadata.args) ? muxMetadata.args : [],
313+
timestamp: muxMetadata.timestamp ?? message.metadata?.timestamp ?? Date.now(),
314+
};
315+
this.pendingScriptExecutions.set(message.id, info);
316+
return;
293317
}
318+
319+
this.pendingScriptExecutions.delete(message.id);
294320
}
295321

296322
private clearScriptExecutionState(messageId: string): void {
297323
this.pendingScriptExecutions.delete(messageId);
298324
}
299325

326+
private setPendingStreamStartTime(time: number | null): void {
327+
this.pendingStreamStartTime = time;
328+
}
329+
300330
getActiveStreams(): StreamingContext[] {
301331
return Array.from(this.activeStreams.values());
302332
}

0 commit comments

Comments
 (0)