feat: select rewind points directly from Console history
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -155,10 +155,6 @@ symlink_policy: SymlinkPolicy, };
|
||||
|
||||
export type CompletionEntry = { value: string, is_dir: boolean, description?: string | null, usage?: string | null, invocation?: FeatureInvocationDescriptor | null, };
|
||||
|
||||
export type RewindTargetId = { segment_id: string, user_input_entry_index: number, };
|
||||
|
||||
export type RewindTarget = { id: RewindTargetId, expected_head_entries: number, truncate_entries: number, turn_index: number, timestamp_ms: number | null, preview: string, eligible: boolean, disabled_reason: string | null, warning: string | null, };
|
||||
|
||||
export type RewindSummary = { truncated_to_entries: number, discarded_entries: number, tool_side_effect_warning: boolean, };
|
||||
|
||||
export type InFlightBlock = { "kind": "text", text: string, finished?: boolean, } | { "kind": "thinking", text: string, finished?: boolean, } | { "kind": "tool_call", id: string, name: string, args: string, state?: InFlightToolCallState, };
|
||||
@@ -416,7 +412,7 @@ export type SubscriptionFramePayload = { "frame": "request", "message": Subscrip
|
||||
|
||||
export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod });
|
||||
|
||||
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, } } | { "method": "clear_pending_submissions", "params": { expected_submission_ids: Array<string>, } } | { "method": "continue_pending", "params": { expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string,
|
||||
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, } } | { "method": "clear_pending_submissions", "params": { expected_submission_ids: Array<string>, } } | { "method": "continue_pending", "params": { expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "rewind_to", "params": { entry_id: string, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string,
|
||||
/**
|
||||
* Client query identity. Echoed verbatim so identical-prefix ABA
|
||||
* responses cannot cross edits, target switches, or permission changes.
|
||||
@@ -468,4 +464,4 @@ in_flight?: InFlightSnapshot,
|
||||
* Parent-owned Internal Worker sessions visible to this client.
|
||||
* Service-private Internal Workers are deliberately excluded.
|
||||
*/
|
||||
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, prefix: string, request_id?: string | null, context?: CompletionContext | null, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
|
||||
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, prefix: string, request_id?: string | null, context?: CompletionContext | null, entries: Array<CompletionEntry>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compaction_progress", "data": { compaction: InFlightCompaction | null, } } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
|
||||
|
||||
@@ -738,7 +738,10 @@
|
||||
state: EditorState.create({
|
||||
extensions: editorExtensions = [
|
||||
history(),
|
||||
Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })),
|
||||
Prec.highest(EditorView.domEventHandlers({ keydown(event) {
|
||||
onkeydown?.(event);
|
||||
return event.defaultPrevented || completionKeydown(event);
|
||||
} })),
|
||||
Prec.highest(keymap.of([
|
||||
{
|
||||
key: "ArrowUp",
|
||||
@@ -851,8 +854,6 @@
|
||||
return true;
|
||||
},
|
||||
keydown(event) {
|
||||
onkeydown?.(event);
|
||||
if (event.defaultPrevented) return true;
|
||||
if (
|
||||
shouldSubmitChatKey(event, {
|
||||
mode: "mod-enter",
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
|
||||
type Props = {
|
||||
item: ConsoleLine;
|
||||
rewindSelected?: boolean;
|
||||
attachmentUrl?: (attachment: SessionToolAttachment) => string | null;
|
||||
};
|
||||
|
||||
let { item, attachmentUrl = () => null }: Props = $props();
|
||||
let { item, rewindSelected = false, attachmentUrl = () => null }: Props = $props();
|
||||
let detailOpen = $state(false);
|
||||
let nowMs = $state(Date.now());
|
||||
|
||||
@@ -83,6 +84,8 @@
|
||||
<li
|
||||
class={`console-line ${lineClass(item)} ${toolClass(item)}`}
|
||||
class:error-line={item.error}
|
||||
class:rewind-selected={rewindSelected}
|
||||
aria-current={rewindSelected ? "true" : undefined}
|
||||
data-console-line-id={item.id}
|
||||
>
|
||||
{#if item.compaction}
|
||||
@@ -165,6 +168,12 @@
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.rewind-selected {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.console-line {
|
||||
min-width: 0;
|
||||
padding: var(--space-1) 0;
|
||||
|
||||
@@ -17,7 +17,7 @@ Deno.test("compact and rewind remain explicit Composer commands", () => {
|
||||
ok: true, request: { kind: "compact", content: "" }, notice: "compact requested",
|
||||
});
|
||||
assertEquals(buildComposerRequest(":rewind"), {
|
||||
ok: true, request: { kind: "list_rewind_targets", content: "" }, notice: "rewind targets requested",
|
||||
ok: true, action: "rewind",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ export type WorkerConsoleInputKind =
|
||||
| "user"
|
||||
| "notify"
|
||||
| "compact"
|
||||
| "list_rewind_targets"
|
||||
| "register_peer";
|
||||
|
||||
export type WorkerConsoleInputRequest = {
|
||||
@@ -15,7 +14,7 @@ export type WorkerConsoleInputRequest = {
|
||||
};
|
||||
|
||||
export type ComposerCommandResult =
|
||||
| { ok: true; request?: WorkerConsoleInputRequest; notice?: string }
|
||||
| { ok: true; request?: WorkerConsoleInputRequest; action?: "rewind"; notice?: string }
|
||||
| { ok: false; message: string };
|
||||
|
||||
type CommandSpec = {
|
||||
@@ -43,7 +42,7 @@ export const COMMANDS: Record<string, CommandSpec> = {
|
||||
},
|
||||
rewind: {
|
||||
usage: ":rewind",
|
||||
description: "Ask the Worker for rewind targets.",
|
||||
description: "Select a user input in Console history with ↑/↓, then Enter to rewind.",
|
||||
},
|
||||
rollback: {
|
||||
usage: ":rollback",
|
||||
@@ -200,8 +199,7 @@ function buildColonCommand(commandLine: string): ComposerCommandResult {
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
request: { kind: "list_rewind_targets", content: "" },
|
||||
notice: "rewind targets requested",
|
||||
action: "rewind",
|
||||
};
|
||||
case "peer":
|
||||
if (argv.length !== 1) {
|
||||
|
||||
@@ -1325,7 +1325,6 @@ export function applyProtocolEvent(
|
||||
break;
|
||||
case "memory_worker":
|
||||
case "completions":
|
||||
case "rewind_targets":
|
||||
case "workers_listed":
|
||||
case "worker_restored":
|
||||
case "peer_registered":
|
||||
|
||||
@@ -913,7 +913,7 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn
|
||||
!page.includes("margin-inline: calc(-1 * var(--space-6))") &&
|
||||
page.includes("import { pushWorkspaceAlert }") &&
|
||||
page.includes('title: "Worker control"') &&
|
||||
page.includes('title: "Rewind targets"') &&
|
||||
!page.includes('aria-label="Rewind targets"') &&
|
||||
page.includes('pushWorkspaceAlert("error"') &&
|
||||
!page.includes("controlNotice") &&
|
||||
!page.includes("console-notice") &&
|
||||
@@ -1271,7 +1271,7 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime
|
||||
'const workerRunning = $derived(workerState === "running");',
|
||||
) &&
|
||||
consolePage.includes(
|
||||
'const canSendComposerControl = $derived(protocolState === "open" && !sending);',
|
||||
'const canSendComposerControl = $derived(protocolState === "open" && !sending && !rewindApplying);',
|
||||
) &&
|
||||
consolePage.includes('sendWorkerControl("cancel")') &&
|
||||
consolePage.includes("lifecycleMethod(command)") &&
|
||||
@@ -1280,7 +1280,7 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime
|
||||
consolePage.includes("onsubmit={handleComposerSubmit}") &&
|
||||
!consolePage.includes("disabled={!composerEditable}") &&
|
||||
consolePage.includes("if (!canSendComposerControl) return;") &&
|
||||
consolePage.includes("class:stop={workerRunning}") &&
|
||||
consolePage.includes("class:stop={workerRunning && !rewindEntryId}") &&
|
||||
consolePage.includes('"Stop Worker"') &&
|
||||
consolePage.includes("disabled={composerSubmitDisabled}") &&
|
||||
!consolePage.includes("disabled={!inputReady || sending}") &&
|
||||
|
||||
+128
-83
@@ -44,7 +44,6 @@
|
||||
Event as ProtocolEvent,
|
||||
Method as ProtocolMethod,
|
||||
PendingSubmissionsSnapshot,
|
||||
RewindTarget,
|
||||
Segment,
|
||||
SessionHistoryPage,
|
||||
SessionToolAttachment,
|
||||
@@ -176,8 +175,9 @@
|
||||
reportComposerError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
let rewindTargets = $state<RewindTarget[]>([]);
|
||||
let rewindHeadEntries = $state(0);
|
||||
let rewindEntryId = $state<string | null>(null);
|
||||
let rewindApplying = $state(false);
|
||||
let rewindLoadingEarlier = false;
|
||||
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
|
||||
"connecting",
|
||||
);
|
||||
@@ -305,6 +305,7 @@
|
||||
const turnNavigationItems = $derived(
|
||||
conversationTurnPreviewsFromLines(lines),
|
||||
);
|
||||
const rewindEntries = $derived(lines.filter((line) => line.kind === "user" && line.entryId));
|
||||
const tasks = $derived(selectedConsoleProjection.tasks);
|
||||
const diagnostics = $derived(
|
||||
mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics),
|
||||
@@ -314,7 +315,7 @@
|
||||
);
|
||||
const workerRunning = $derived(workerState === "running");
|
||||
const workerPaused = $derived(workerState === "paused");
|
||||
const canSendComposerControl = $derived(protocolState === "open" && !sending);
|
||||
const canSendComposerControl = $derived(protocolState === "open" && !sending && !rewindApplying);
|
||||
const canUploadAttachments = $derived(consoleTarget !== null);
|
||||
const draftHasText = $derived(draft.content.trim().length > 0);
|
||||
const draftHasAttachments = $derived(
|
||||
@@ -325,7 +326,7 @@
|
||||
delivery: "submit",
|
||||
workerState,
|
||||
protocolOpen: protocolState === "open",
|
||||
sending,
|
||||
sending: sending || rewindApplying,
|
||||
hasText: draftHasText,
|
||||
hasAttachments: draftHasAttachments,
|
||||
}),
|
||||
@@ -335,7 +336,7 @@
|
||||
delivery: "queue",
|
||||
workerState,
|
||||
protocolOpen: protocolState === "open",
|
||||
sending,
|
||||
sending: sending || rewindApplying,
|
||||
hasText: draftHasText,
|
||||
hasAttachments: draftHasAttachments,
|
||||
}),
|
||||
@@ -345,14 +346,14 @@
|
||||
delivery: "notify",
|
||||
workerState,
|
||||
protocolOpen: protocolState === "open",
|
||||
sending,
|
||||
sending: sending || rewindApplying,
|
||||
hasText: draftHasText,
|
||||
hasAttachments: draftHasAttachments,
|
||||
}),
|
||||
);
|
||||
const canStopFromComposer = $derived(workerRunning && canSendComposerControl);
|
||||
const composerSubmitDisabled = $derived(
|
||||
workerRunning ? !canStopFromComposer : !canSubmitDraft,
|
||||
rewindEntryId ? !canSendComposerControl : workerRunning ? !canStopFromComposer : !canSubmitDraft,
|
||||
);
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
@@ -773,6 +774,8 @@
|
||||
) {
|
||||
handleProtocolCommandEvent(payload);
|
||||
if (payload.event === "rewind_applied") {
|
||||
rewindApplying = false;
|
||||
rewindEntryId = null;
|
||||
if (draft.segments.length === 0 && attachments.length === 0) {
|
||||
composerInputElement?.restoreSegments(payload.data.input);
|
||||
} else {
|
||||
@@ -824,6 +827,7 @@
|
||||
}
|
||||
|
||||
function handleComposerKeydown(event: KeyboardEvent) {
|
||||
if (handleRewindKeydown(event)) return;
|
||||
if (event.key === "PageUp" || event.key === "PageDown") {
|
||||
event.preventDefault();
|
||||
scrollConsoleByPage(event.key === "PageDown" ? 1 : -1);
|
||||
@@ -934,17 +938,88 @@
|
||||
sendWorkerControl(command);
|
||||
}
|
||||
|
||||
function rewindTo(target: RewindTarget) {
|
||||
sendControl(
|
||||
{
|
||||
method: "rewind_to",
|
||||
params: {
|
||||
target: target.id,
|
||||
expected_head_entries: rewindHeadEntries,
|
||||
},
|
||||
},
|
||||
`Rewind to ${target.preview || "target"}`,
|
||||
);
|
||||
async function startRewindSelection(): Promise<void> {
|
||||
if (rewindApplying) return;
|
||||
const targetKey = activeComposerTargetKey;
|
||||
await selectConsoleWorkerView(null);
|
||||
if (activeComposerTargetKey !== targetKey) return;
|
||||
const target = rewindEntries.at(-1);
|
||||
if (!target?.entryId) {
|
||||
reportComposerError("No user input is loaded. Load earlier conversation to select a rewind point.");
|
||||
return;
|
||||
}
|
||||
composerInputElement?.clear();
|
||||
rewindEntryId = target.entryId;
|
||||
autoFollowConsole = false;
|
||||
await tick();
|
||||
if (rewindEntryId !== target.entryId) return;
|
||||
selectConsoleTurn(target.id);
|
||||
autoFollowConsole = false;
|
||||
composerInputElement?.focus();
|
||||
}
|
||||
|
||||
async function moveRewindSelection(direction: -1 | 1): Promise<void> {
|
||||
if (!rewindEntryId || rewindApplying || rewindLoadingEarlier) return;
|
||||
const targetKey = activeComposerTargetKey;
|
||||
const selected = rewindEntryId;
|
||||
let index = rewindEntries.findIndex((entry) => entry.entryId === selected);
|
||||
if (index === 0 && direction === -1 && selectedHistory.hasMore && selectedHistory.cursor && consoleTarget) {
|
||||
rewindLoadingEarlier = true;
|
||||
try {
|
||||
await requestHistoryPage(consoleTarget, selectedHistoryKey, selectedHistory.cursor, reloadToken);
|
||||
await tick();
|
||||
} finally { rewindLoadingEarlier = false; }
|
||||
if (activeComposerTargetKey !== targetKey || rewindEntryId !== selected) return;
|
||||
index = rewindEntries.findIndex((entry) => entry.entryId === selected);
|
||||
}
|
||||
if (index < 0) { rewindEntryId = null; return; }
|
||||
const target = rewindEntries[Math.max(0, Math.min(rewindEntries.length - 1, index + direction))];
|
||||
rewindEntryId = target.entryId!;
|
||||
await tick();
|
||||
if (rewindEntryId !== target.entryId) return;
|
||||
selectConsoleTurn(target.id);
|
||||
autoFollowConsole = false;
|
||||
}
|
||||
|
||||
function applySelectedRewind(): void {
|
||||
if (!rewindEntryId || rewindApplying) return;
|
||||
if (!canSendComposerControl) {
|
||||
reportComposerError("Cannot send rewind while disconnected or another input is awaiting admission.");
|
||||
return;
|
||||
}
|
||||
if (!rewindEntries.some((entry) => entry.entryId === rewindEntryId)) {
|
||||
reportComposerError("The selected input is no longer in the displayed history.");
|
||||
rewindEntryId = null;
|
||||
return;
|
||||
}
|
||||
rewindApplying = true;
|
||||
try {
|
||||
sendProtocolMethod({ method: "rewind_to", params: { entry_id: rewindEntryId } });
|
||||
} catch (error) {
|
||||
rewindApplying = false;
|
||||
reportComposerError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function handleRewindKeydown(event: KeyboardEvent): boolean {
|
||||
if (!rewindEntryId || event.isComposing || event.keyCode === 229 ||
|
||||
event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false;
|
||||
if (["ArrowUp", "ArrowDown", "Enter", "Escape"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") rewindEntryId = null;
|
||||
else if (event.key === "Enter") { if (!event.repeat) applySelectedRewind(); }
|
||||
else void moveRewindSelection(event.key === "ArrowUp" ? -1 : 1);
|
||||
return true;
|
||||
}
|
||||
// Typing resumes normal draft editing without disabling the composer.
|
||||
if (event.key.length === 1 || event.key === "Backspace" || event.key === "Delete") rewindEntryId = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleConsoleKeydown(event: KeyboardEvent): void {
|
||||
if (!event.defaultPrevented && !isEditableTarget(event.target) && handleRewindKeydown(event)) return;
|
||||
handleWorkerControlShortcut(event);
|
||||
}
|
||||
|
||||
function composerRequestToProtocolMethod(
|
||||
@@ -974,8 +1049,6 @@
|
||||
if (!method) throw new Error("Worker state snapshot is not available");
|
||||
return method;
|
||||
}
|
||||
case "list_rewind_targets":
|
||||
return { method: "list_rewind_targets" };
|
||||
case "register_peer":
|
||||
return {
|
||||
method: "register_peer",
|
||||
@@ -992,6 +1065,7 @@
|
||||
}
|
||||
|
||||
function handleComposerChange(snapshot: ComposerDraftSnapshot) {
|
||||
if (snapshot.document !== draft.document) rewindEntryId = null;
|
||||
draft = snapshot;
|
||||
composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
|
||||
}
|
||||
@@ -1057,13 +1131,14 @@
|
||||
}
|
||||
|
||||
function handleComposerSubmit() {
|
||||
if (!canSendComposerControl) return;
|
||||
if (rewindEntryId) { applySelectedRewind(); return; }
|
||||
const value = composerSnapshotOrReport();
|
||||
if (!value) return;
|
||||
if (value.document.trimStart().startsWith(":")) {
|
||||
handleComposerCommand();
|
||||
return;
|
||||
}
|
||||
if (!canSendComposerControl) return;
|
||||
if (workerRunning) {
|
||||
sendWorkerControl("cancel");
|
||||
return;
|
||||
@@ -1275,6 +1350,11 @@
|
||||
reportComposerError(command.message);
|
||||
return;
|
||||
}
|
||||
if (command.action === "rewind") {
|
||||
if (delivery !== "submit") { reportComposerError("Rewind is a local Console action, not queued input or a notification."); return; }
|
||||
await startRewindSelection();
|
||||
return;
|
||||
}
|
||||
if (!command.request) {
|
||||
if (command.notice) pushWorkspaceAlert("info", command.notice, { title: "Console command" });
|
||||
composerInputElement?.clear();
|
||||
@@ -1284,7 +1364,7 @@
|
||||
delivery,
|
||||
workerState,
|
||||
protocolOpen: protocolState === "open",
|
||||
sending,
|
||||
sending: sending || rewindApplying,
|
||||
hasText: value.content.trim().length > 0,
|
||||
hasAttachments: attachments.length > 0 ||
|
||||
value.segments.some((segment) => segment.kind === "uploaded_file"),
|
||||
@@ -1622,19 +1702,11 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.event === "rewind_targets") {
|
||||
rewindHeadEntries = event.data.head_entries;
|
||||
rewindTargets = event.data.targets;
|
||||
pushWorkspaceAlert(
|
||||
"info",
|
||||
event.data.targets.length === 0
|
||||
? "No rewind targets are available."
|
||||
: `Loaded ${event.data.targets.length} rewind target(s).`,
|
||||
{ id: controlAlertId, title: "Rewind targets" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.event === "error") {
|
||||
if (rewindApplying) {
|
||||
rewindApplying = false;
|
||||
reportComposerError(event.data.message);
|
||||
}
|
||||
const error = new Error(event.data.message);
|
||||
if (
|
||||
fileCompletions.pending || featureCompletions.pending ||
|
||||
@@ -1655,6 +1727,8 @@
|
||||
|
||||
function rejectPendingCompletion(error: Error, targetKey = activeComposerTargetKey) {
|
||||
admissionDisconnected(targetKey);
|
||||
rewindEntryId = null;
|
||||
rewindApplying = false;
|
||||
// Invalidate suggestions on disconnect without locking the draft.
|
||||
composerInputElement?.closeCompletion();
|
||||
fileCompletions.close(error);
|
||||
@@ -1693,6 +1767,7 @@
|
||||
rememberCurrent = true,
|
||||
) {
|
||||
if (sessionId === selectedWorkerViewSessionId) return;
|
||||
rewindEntryId = null;
|
||||
const generation = ++workerViewSelectionGeneration;
|
||||
if (rememberCurrent) rememberConsoleWorkerViewScroll();
|
||||
const target = workerViews.find((view) => view.sessionId === sessionId) ??
|
||||
@@ -1799,6 +1874,8 @@
|
||||
const target = consoleTarget;
|
||||
const targetWorker = data.worker;
|
||||
const targetWorkerError = data.workerError;
|
||||
rewindEntryId = null;
|
||||
rewindApplying = false;
|
||||
workerViewSelectionGeneration += 1;
|
||||
selectedWorkerViewSessionId = null;
|
||||
consoleViewScroll.clear();
|
||||
@@ -1854,7 +1931,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleWorkerControlShortcut} />
|
||||
<svelte:window onkeydown={handleConsoleKeydown} />
|
||||
|
||||
<svelte:head>
|
||||
<title>Worker Console · Yoi Workspace</title>
|
||||
@@ -1865,28 +1942,6 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="console-shell worker-console-shell">
|
||||
{#if rewindTargets.length > 0}
|
||||
<section class="card rewind-targets" aria-label="Rewind targets">
|
||||
<h3>Rewind targets</h3>
|
||||
<div class="rewind-target-list">
|
||||
{#each rewindTargets as target (JSON.stringify(target.id))}
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
disabled={protocolState !== "open" || !target.eligible}
|
||||
title={target.disabled_reason ?? target.warning ?? undefined}
|
||||
onclick={() => rewindTo(target)}
|
||||
>
|
||||
{target.preview || `${target.turn_index}`}
|
||||
{#if target.warning || target.disabled_reason}
|
||||
<span>{target.warning ?? target.disabled_reason}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#key selectedHistoryKey}
|
||||
<svelte:boundary onerror={(error) => console.error("Console transcript rendering failed", error)}>
|
||||
<div class:with-task-pane={taskPaneOpen} class="console-history">
|
||||
@@ -1932,7 +1987,7 @@
|
||||
{#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0}
|
||||
<ol class="console-log">
|
||||
{#each lines as item (item.id)}
|
||||
<ConsoleLineItem {item} {attachmentUrl} />
|
||||
<ConsoleLineItem {item} {attachmentUrl} rewindSelected={rewindEntryId !== null && item.entryId === rewindEntryId} />
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
@@ -2151,6 +2206,12 @@
|
||||
accept="text/*,application/json,application/pdf,image/png,image/jpeg,image/gif,image/webp"
|
||||
onchange={handleFileInput}
|
||||
/>
|
||||
{#if rewindEntryId || rewindApplying}
|
||||
<div class="rewind-status" role="status">
|
||||
{rewindApplying ? "Applying rewind…" : "Rewind · ↑↓ select input · Enter apply · Esc cancel"}
|
||||
<span>Rewinds to before the selected input. Tool side effects are not undone.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<ComposerInput
|
||||
bind:this={composerInputElement}
|
||||
historyScope={workspaceId}
|
||||
@@ -2268,16 +2329,18 @@
|
||||
{/if}
|
||||
<button
|
||||
class="composer-send-button"
|
||||
class:stop={workerRunning}
|
||||
class:stop={workerRunning && !rewindEntryId}
|
||||
type="submit"
|
||||
aria-label={workerRunning
|
||||
aria-label={rewindEntryId ? "Rewind to selected input" : workerRunning
|
||||
? "Stop Worker"
|
||||
: sending
|
||||
? "Sending message"
|
||||
: "Send message"}
|
||||
disabled={composerSubmitDisabled}
|
||||
>
|
||||
{#if workerRunning}
|
||||
{#if rewindEntryId}
|
||||
<svg class="composer-send-icon" aria-hidden="true" viewBox="0 0 24 24"><path d="M9 5L3 11L9 17M3 11H15A5 5 0 0 1 15 21" /></svg>
|
||||
{:else if workerRunning}
|
||||
<svg
|
||||
class="composer-send-icon"
|
||||
aria-hidden="true"
|
||||
@@ -2325,6 +2388,9 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rewind-status { font-size: var(--font-size-compact); color: var(--text-muted); }
|
||||
.rewind-status span { display: block; }
|
||||
|
||||
.console-shell {
|
||||
width: 100%;
|
||||
max-width: 920px;
|
||||
@@ -2409,28 +2475,7 @@
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
.rewind-targets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.rewind-targets h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.rewind-target-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.rewind-target-list span {
|
||||
margin-left: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.console-history {
|
||||
display: grid;
|
||||
|
||||
+121
-1
@@ -947,11 +947,15 @@ test.each(["idle", "running", "paused"] as const)("Composer completion executes
|
||||
method: "compact", params: { command: expect.objectContaining({ command_id: 1 }) },
|
||||
}));
|
||||
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
|
||||
protocolEvent({ event: "user_message", data: { entry_id: "rewind-user", segments: [{ kind: "text", content: "rewind input" }] } });
|
||||
await screen.findByText("rewind input");
|
||||
const callsBeforeRewind = multiplexer.sendWorkerMethod.mock.calls.length;
|
||||
cm.dispatch({ changes: { from: 0, insert: ":rew" }, selection: { anchor: 4 } });
|
||||
const option = await screen.findByRole("option");
|
||||
await fireEvent.pointerDown(option);
|
||||
await fireEvent.click(option);
|
||||
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_rewind_targets" }));
|
||||
await waitFor(() => expect(view.container.querySelector(".rewind-selected")?.textContent).toContain("rewind input"));
|
||||
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledTimes(callsBeforeRewind);
|
||||
expect(multiplexer.sendWorkerMethod.mock.calls.every(([method]) => method.method !== "submit" && method.method !== "cancel")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1701,3 +1705,119 @@ test("pending groups independently follow authoritative changes, including zero
|
||||
latestListener().onFrame(subscribedFrame(session));
|
||||
await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull());
|
||||
});
|
||||
|
||||
|
||||
async function enterRewind(cm: EditorView, form: HTMLFormElement) {
|
||||
cm.focus();
|
||||
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: ":rewind" }, selection: { anchor: 7 } });
|
||||
await fireEvent.submit(form);
|
||||
await waitFor(() => expect(document.querySelector(".rewind-selected")).not.toBeNull());
|
||||
}
|
||||
|
||||
function rewindSession(): SessionSnapshot {
|
||||
return { ...emptySession(), entries: ["first", "second"].map((id) => ({
|
||||
kind: "user_input", entry_id: id, timestamp: 1, provenance: "human_input", derived_from: [],
|
||||
segments: [{ kind: "text", content: "identical input" }],
|
||||
})) };
|
||||
}
|
||||
|
||||
test("rewind selects existing history by ID, follows arrows, tolerates appended history and suppresses repeated Enter", async () => {
|
||||
const { cm, form, view } = await liveComposer();
|
||||
latestListener().onFrame(subscribedFrame(rewindSession()));
|
||||
await waitFor(() => expect(view.container.querySelectorAll(".console-line.user")).toHaveLength(2));
|
||||
const rows = () => [...view.container.querySelectorAll(".console-line.user")];
|
||||
await enterRewind(cm, form);
|
||||
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
|
||||
expect(rows()[1].classList.contains("rewind-selected")).toBe(true);
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowUp" });
|
||||
expect(rows()[0].classList.contains("rewind-selected")).toBe(true);
|
||||
expect(cm.state.doc.toString()).toBe("");
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowDown" });
|
||||
expect(rows()[1].classList.contains("rewind-selected")).toBe(true);
|
||||
protocolEvent({ event: "user_message", data: { entry_id: "third", segments: [{ kind: "text", content: "newly appended" }] } });
|
||||
await screen.findByText("newly appended");
|
||||
expect(rows()[1].classList.contains("rewind-selected")).toBe(true);
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: "Enter", repeat: true });
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
|
||||
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledExactlyOnceWith({ method: "rewind_to", params: { entry_id: "second" } });
|
||||
expect(cm.state.readOnly).toBe(false);
|
||||
protocolEvent({ event: "rewind_applied", data: { session: { ...rewindSession(), entries: rewindSession().entries.slice(0, 1) }, input: [{ kind: "text", content: "identical input" }], summary: { truncated_to_entries: 1, discarded_entries: 2, tool_side_effect_warning: true } } });
|
||||
await waitFor(() => expect(cm.state.doc.toString()).toBe("identical input"));
|
||||
expect(view.container.querySelector(".rewind-selected")).toBeNull();
|
||||
expect(cm.hasFocus).toBe(true);
|
||||
});
|
||||
|
||||
test("rewind Escape and draft editing leave selection without sending; rejection keeps it available", async () => {
|
||||
const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
|
||||
const { cm, form, view } = await liveComposer();
|
||||
latestListener().onFrame(subscribedFrame(rewindSession()));
|
||||
await waitFor(() => expect(view.container.querySelectorAll(".console-line.user")).toHaveLength(2));
|
||||
await enterRewind(cm, form);
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: "Escape" });
|
||||
expect(view.container.querySelector(".rewind-selected")).toBeNull();
|
||||
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
|
||||
await enterRewind(cm, form);
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
|
||||
protocolEvent({ event: "error", data: { code: "invalid_request", message: "Target is outside current segment" } });
|
||||
await waitFor(() => expect(alert).toHaveBeenCalledWith("error", "Target is outside current segment", expect.any(Object)));
|
||||
expect(view.container.querySelector(".rewind-selected")).not.toBeNull();
|
||||
expect((screen.getByRole("button", { name: "Rewind to selected input" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
await fireEvent.paste(cm.contentDOM, { clipboardData: { getData: () => "new draft" } });
|
||||
expect(cm.state.doc.toString()).toBe("new draft");
|
||||
expect(view.container.querySelector(".rewind-selected")).toBeNull();
|
||||
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rewind reply preserves draft edits made while applying and selection is cleared on target change", async () => {
|
||||
const { cm, form, view } = await liveComposer();
|
||||
latestListener().onFrame(subscribedFrame(rewindSession()));
|
||||
await waitFor(() => expect(view.container.querySelectorAll(".console-line.user")).toHaveLength(2));
|
||||
await enterRewind(cm, form);
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
|
||||
await fireEvent.paste(cm.contentDOM, { clipboardData: { getData: () => "keep this edit" } });
|
||||
protocolEvent({ event: "rewind_applied", data: { session: rewindSession(), input: [{ kind: "text", content: "old input" }], summary: { truncated_to_entries: 1, discarded_entries: 1, tool_side_effect_warning: false } } });
|
||||
await settleMicrotasks();
|
||||
expect(cm.state.doc.toString()).toBe("keep this edit");
|
||||
await enterRewind(cm, form);
|
||||
latestListener().onStatus?.("closed", { kind: "transport", message: "disconnected" });
|
||||
await waitFor(() => expect(view.container.querySelector(".rewind-selected")).toBeNull());
|
||||
expect(cm.state.readOnly).toBe(false);
|
||||
latestListener().onFrame(subscribedFrame(rewindSession()));
|
||||
await enterRewind(cm, form);
|
||||
await view.rerender({ data: pageData("worker-b", "runtime-b") });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
|
||||
expect(view.container.querySelector(".rewind-selected")).toBeNull();
|
||||
});
|
||||
|
||||
test("rewind Up loads the next history page and selects its nearest user input", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
return Response.json(!url.includes("/session/history") ? { availability: "live_protocol" }
|
||||
: url.includes("cursor=") ? historyPage([1], null) : historyPage([2], "older-2"));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const view = render(ConsolePage, { data: pageData() });
|
||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||
latestListener().onFrame(subscribedFrame());
|
||||
await waitFor(() => expect(view.container.querySelector(".console-line.user")?.textContent).toContain("question 2"));
|
||||
const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
|
||||
await enterRewind(cm, view.container.querySelector(".console-composer") as HTMLFormElement);
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowUp" });
|
||||
await waitFor(() => expect(view.container.querySelector(".rewind-selected")?.textContent).toContain("question 1"));
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
|
||||
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledExactlyOnceWith({ method: "rewind_to", params: { entry_id: "user-1" } });
|
||||
});
|
||||
|
||||
|
||||
test("rewind does not confirm an IME Enter", async () => {
|
||||
const { cm, form, view } = await liveComposer();
|
||||
latestListener().onFrame(subscribedFrame(rewindSession()));
|
||||
await waitFor(() => expect(view.container.querySelectorAll(".console-line.user")).toHaveLength(2));
|
||||
await enterRewind(cm, form);
|
||||
await fireEvent.compositionStart(cm.contentDOM);
|
||||
await fireEvent.keyDown(cm.contentDOM, { key: "Enter", isComposing: true, keyCode: 229 });
|
||||
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
|
||||
expect(cm.state.readOnly).toBe(false);
|
||||
await fireEvent.compositionEnd(cm.contentDOM);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user