feat(conversations,api,ui): rewind takes the last turns of a thread back, the cut session stays in the store

This commit is contained in:
hh committed 2026-10-13 02:10:03 +02:00
1 parent b0e5aaf599
commit 2a47c6771d
14 files changed
+583 -9

No files matched your search

+30 -1
View File
@@ -20,13 +20,14 @@ except ImportError: # pragma: no cover
_cli_version = "2.1.248" _cli_version = "2.1.248"
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterable from collections.abc import Iterable, Sequence
__all__ = [ __all__ = [
"CLI_VERSION", "CLI_VERSION",
"build_entries", "build_entries",
"close_open_tool_uses", "close_open_tool_uses",
"fingerprint", "fingerprint",
"last_message_before",
"messages_from_entries", "messages_from_entries",
"open_tool_uses", "open_tool_uses",
"prompt_count", "prompt_count",
@@ -35,6 +36,7 @@ __all__ = [
"spoken_text", "spoken_text",
"strip_tool_entries", "strip_tool_entries",
"text_of", "text_of",
"turn_starts",
"window_entries", "window_entries",
] ]
@@ -438,6 +440,33 @@ def prompt_count(entries: Iterable[Mapping[str, Any]]) -> int:
return sum(1 for e in entries if _is_prompt_entry(e)) return sum(1 for e in entries if _is_prompt_entry(e))
def turn_starts(entries: Iterable[Mapping[str, Any]]) -> list[int]:
"""Where each turn opens: the index of every prompt the SDK was handed.
The CLI stamps only those with ``promptSource``; what it writes as a
user on its own (an interruption note, a skill body) stays inside the
turn it belongs to.
"""
return [
index
for index, entry in enumerate(entries)
if entry.get("promptSource") and _is_prompt_entry(entry)
]
def last_message_before(entries: Sequence[Mapping[str, Any]], index: int) -> str | None:
"""Uuid of the last ``user``/``assistant`` entry ahead of ``index``."""
for entry in reversed(entries[:index]):
uuid = entry.get("uuid")
if (
entry.get("type") in _PROMPT_TYPES
and isinstance(uuid, str)
and not entry.get("isSidechain")
):
return uuid
return None
def spoken_text(block: Any, *, thinking_is_progress: bool) -> str: def spoken_text(block: Any, *, thinking_is_progress: bool) -> str:
"""What a content block (dict or model) says to the human. """What a content block (dict or model) says to the human.
@@ -7,7 +7,7 @@ import contextlib
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4 from uuid import uuid4
from beaver_gateway.conversations.spawning import Spawning from beaver_gateway.conversations.rewinding import Rewinding
from beaver_gateway.conversations.state import Question from beaver_gateway.conversations.state import Question
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
__all__ = ["Questions"] __all__ = ["Questions"]
class Questions(Spawning): class Questions(Rewinding):
async def ask(self, key: str, payload: dict[str, Any]) -> str | None: async def ask(self, key: str, payload: dict[str, Any]) -> str | None:
"""Show the question and wait; ``None`` when nobody answered in time.""" """Show the question and wait; ``None`` when nobody answered in time."""
conv = await self.get(key) conv = await self.get(key)
@@ -0,0 +1,114 @@
"""Taking turns back: the thread moves to a fork that ends before them."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast
from claude_agent_sdk import fork_session_via_store
from beaver_gateway.backends.transcript import (
last_message_before,
messages_from_entries,
reply_text,
text_of,
turn_starts,
)
from beaver_gateway.conversations.spawning import Spawning
if TYPE_CHECKING:
from collections.abc import Sequence
from beaver_gateway.storage.models import Conversation
__all__ = ["Rewinding", "Rewound"]
_log = logging.getLogger(__name__)
PREVIEW = 280
@dataclass(frozen=True, slots=True)
class Rewound:
conversation: Conversation
turns: int
previous_session: str
class Rewinding(Spawning):
async def turns(
self, conv: Conversation, *, limit: int = 10
) -> list[dict[str, Any]]:
"""The latest turns that can be taken back, oldest first; the first stays."""
entries = await self.entries(conv)
starts = turn_starts(entries)
ends = [*starts[1:], len(entries)]
spans = list(zip(starts, ends, strict=True))[1:]
agent = self._agents.get(conv.agent_name)
progress = agent is not None and agent.thinking_is_progress
return [
self._turn(entries[start:end], thinking_is_progress=progress)
for start, end in spans[-limit:]
]
def _turn(
self, entries: Sequence[Any], *, thinking_is_progress: bool
) -> dict[str, Any]:
prompt, *rest = messages_from_entries(entries)
replies = (
reply_text(m["content"], thinking_is_progress=thinking_is_progress)
for m in reversed(rest)
if m["role"] == "assistant"
)
return {
"id": entries[0].get("uuid"),
"ts": entries[0].get("timestamp"),
"text": text_of(self._unwrapped(prompt)["content"])[:PREVIEW],
"reply": next((text for text in replies if text), "")[:PREVIEW],
"failed": any(e.get("isApiErrorMessage") for e in entries),
}
async def rewind(self, conv: Conversation, turn: str) -> Rewound:
"""Take ``turn`` and every turn after it out of what the model sees.
Nothing is deleted: the conversation moves to a fork of its session
that ends on the message before ``turn``, the cut session stays in
the store under its old id.
"""
row_id = cast("int", conv.id)
runner = self._runner(row_id)
if runner.lock.locked():
msg = f"conversation {conv.external_id} is mid-turn; interrupt it first"
raise RuntimeError(msg)
async with runner.lock:
row = await self.get_row(row_id) or conv
entries = await self.entries(row)
starts = turn_starts(entries)
cut = next((i for i in starts[1:] if entries[i].get("uuid") == turn), None)
keep = last_message_before(entries, cut) if cut is not None else None
if cut is None or keep is None:
msg = f"conversation {row.external_id} has no turn {turn} to take back"
raise LookupError(msg)
previous = cast("str", row.session_id)
forked = await fork_session_via_store(
self._store,
previous,
directory=str(self._claude_agent(row.agent_name).cwd),
up_to_message_id=keep,
)
await self._backend(row.agent_name).close(row.external_id)
async def apply(moved: Conversation) -> None:
moved.session_id = forked.session_id
row = await self._update(row, apply)
turns = sum(1 for start in starts if start >= cut)
_log.info(
"rewound %s by %d turns: session %s -> %s",
row.external_id,
turns,
previous,
forked.session_id,
)
return Rewound(conversation=row, turns=turns, previous_session=previous)
+1 -1
View File
@@ -1,7 +1,7 @@
"""``Conversations`` - the service every frontend, job and gateway tool talks to. """``Conversations`` - the service every frontend, job and gateway tool talks to.
Built as layers, one file each: rows → seeds → turns → messaging → Built as layers, one file each: rows → seeds → turns → messaging →
spawning → questions → closing; this file adds start, stop and restart spawning → rewinding → questions → closing; this file adds start, stop and restart
recovery. A turn started by a user message streams back to whoever asked; recovery. A turn started by a user message streams back to whoever asked;
a turn started by an inject streams nowhere and can only speak via ``say``. a turn started by an inject streams nowhere and can only speak via ``say``.
""" """
+4 -4
View File
@@ -68,14 +68,14 @@ class Turns(Seeds):
backend = self._backend(conv.agent_name) backend = self._backend(conv.agent_name)
turn_id = turn_id or f"turn_{uuid4().hex[:12]}" turn_id = turn_id or f"turn_{uuid4().hex[:12]}"
capture = capture or TurnCapture() capture = capture or TurnCapture()
resume = session_id if session_id is not None else conv.session_id
async with runner.lock: async with runner.lock:
runner.turn_id = turn_id runner.turn_id = turn_id
runner.origin = origin runner.origin = origin
runner.text = shown if shown is not None else _prompt_preview(messages) runner.text = shown if shown is not None else _prompt_preview(messages)
runner.started_at = datetime.now(UTC) runner.started_at = datetime.now(UTC)
runner.tools = {} runner.tools = {}
await self._mark_running(conv, turn_id) row = await self._mark_running(conv, turn_id)
resume = session_id if session_id is not None else row.session_id
before = await self.before_turn(conv) before = await self.before_turn(conv)
self._bus.publish( self._bus.publish(
"turn.start", "turn.start",
@@ -463,12 +463,12 @@ class Turns(Seeds):
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
_log.exception("rate limit write failed") _log.exception("rate limit write failed")
async def _mark_running(self, conv: Conversation, turn_id: str) -> None: async def _mark_running(self, conv: Conversation, turn_id: str) -> Conversation:
async def apply(row: Conversation) -> None: async def apply(row: Conversation) -> None:
row.running_turn = turn_id row.running_turn = turn_id
row.last_activity_at = datetime.now(UTC) row.last_activity_at = datetime.now(UTC)
await self._update(conv, apply) return await self._update(conv, apply)
async def _mark_done( async def _mark_done(
self, conv: Conversation, capture: TurnCapture, *, cut: bool = False self, conv: Conversation, capture: TurnCapture, *, cut: bool = False
@@ -79,6 +79,8 @@ MEMORY_MAX_ENTRIES = 5000
SEARCH_MIN = 2 SEARCH_MIN = 2
GRAPH_LIMIT = 120 GRAPH_LIMIT = 120
SEARCH_LIMIT = 20 SEARCH_LIMIT = 20
TURNS_LIMIT = 10
TURNS_MAX = 50
SNIPPET_MAX = 160 SNIPPET_MAX = 160
@@ -578,6 +580,42 @@ def build_app( # noqa: PLR0915
"text": result.text, "text": result.text,
} }
@app.get("/conversations/{public_id}/turns")
async def get_turns(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
conv = await conv_of(public_id)
limit = min(query_int(request, "limit", TURNS_LIMIT), TURNS_MAX)
return {
"id": conv.external_id,
"turns": await conversations.turns(conv, limit=limit),
}
@app.post("/conversations/{public_id}/rewind")
async def post_rewind(public_id: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=ADMIN_SCOPE)
conv = await conv_of(public_id)
turn = text_of(await body_of(request), "turn")
try:
result = await conversations.rewind(conv, turn)
except LookupError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
out = {
"turns": result.turns,
"session_id": result.conversation.session_id,
"previous_session": result.previous_session,
}
await audit.log(
runtime,
actor=f"token:{token}",
kind="api_rewind",
agent_name=conv.agent_name,
conversation=conv.external_id,
**out,
)
return {"id": conv.external_id, **out}
@app.post("/conversations/{public_id}/bind") @app.post("/conversations/{public_id}/bind")
async def post_bind(public_id: str, request: Request) -> dict[str, Any]: async def post_bind(public_id: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE) await require_token(request, runtime, scope=SCOPE)
+47 -1
View File
@@ -16,7 +16,7 @@ from claude_agent_sdk import (
UserMessage, UserMessage,
) )
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from test_conversations import ScriptedClient, World from test_conversations import ScriptedClient, World, _three_turns
from beaver_gateway.frontends.markdown.history import rewrite_messages from beaver_gateway.frontends.markdown.history import rewrite_messages
from beaver_gateway.security.auth import TokenStore from beaver_gateway.security.auth import TokenStore
@@ -444,6 +444,52 @@ async def test_sessions_show_their_processes_and_take_orders(world: World) -> No
assert kinds == ["session_interrupt", "session_close"] assert kinds == ["session_interrupt", "session_close"]
async def test_rewind_lists_turns_and_needs_admin_scope(world: World) -> None:
api = Api(world)
api.store.grant("api-only", "weak", scope="api")
weak = {"Authorization": "Bearer weak"}
sid, entries = await _three_turns(world)
conv = await world.conversations.create(
kind="master", agent="a", origin="test", session_id=sid
)
path = f"/conversations/{conv.external_id}"
listed = (await api.http.get(f"{path}/turns", headers=weak)).json()
assert [t["text"] for t in listed["turns"]] == ["two", "three"]
assert (await api.get(f"{path}/turns", {"limit": 1}))["turns"] == [
listed["turns"][1]
]
body = {"turn": listed["turns"][0]["id"]}
denied = await api.http.post(f"{path}/rewind", json=body, headers=weak)
assert denied.status_code == 403
missing = await api.http.post(
f"{path}/rewind", json={"turn": entries[0]["uuid"]}, headers=HEADERS
)
assert missing.status_code == 404
done = await api.http.post(f"{path}/rewind", json=body, headers=HEADERS)
assert done.status_code == 200, done.text
out = done.json()
assert out["turns"] == 2 and out["previous_session"] == sid
assert (await api.get(path))["session_id"] == out["session_id"] != sid
assert (await api.get(f"{path}/turns"))["turns"] == []
messages = (await api.get(f"{path}/history"))["messages"]
assert [m["content"] for m in messages] == [
"one",
[{"type": "text", "text": "done one"}],
]
(record,) = (await api.get("/audit"))["records"]
assert record["kind"] == "api_rewind"
assert record["detail"]["previous_session"] == sid
ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "busy")
await asyncio.sleep(0.2)
busy = await api.http.post(f"{path}/rewind", json=body, headers=HEADERS)
assert busy.status_code == 409
ScriptedClient.hold.set()
async def test_tokens_and_audit_need_admin_scope(world: World) -> None: async def test_tokens_and_audit_need_admin_scope(world: World) -> None:
api = Api(world) api = Api(world)
api.store.grant("api-only", "weak", scope="api") api.store.grant("api-only", "weak", scope="api")
+99
View File
@@ -504,6 +504,105 @@ async def test_copy_seed_forks_parent_with_window(world: World) -> None:
assert (await world.conversations.get(child.external_id)).flags["seed"] is None assert (await world.conversations.get(child.external_id)).flags["seed"] is None
async def _three_turns(world: World) -> tuple[str, list[dict[str, Any]]]:
sid = str(uuid.uuid4())
history = [
{"role": "user", "content": "one"},
{"role": "assistant", "content": "done one"},
{"role": "user", "content": "two"},
{
"role": "assistant",
"content": [{"type": "tool_use", "id": "t1", "name": "Read", "input": {}}],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "x"}],
},
{"role": "assistant", "content": "done two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "refused"},
]
entries = build_entries(history, session_id=sid, cwd=str(world.root), model="m")
entries[-1]["isApiErrorMessage"] = True
note = {
**entries[2],
"uuid": str(uuid.uuid4()),
"message": {
"role": "user",
"content": [{"type": "text", "text": "[Request interrupted by user]"}],
},
}
del note["promptSource"]
entries.insert(6, note)
await world.store.append(world.key(sid), entries)
return sid, entries
async def test_rewind_moves_the_thread_to_a_fork_that_ends_before_the_turn(
world: World,
) -> None:
sid, entries = await _three_turns(world)
conv = await world.conversations.create(
kind="master", agent="a", origin="test", session_id=sid
)
await world.conversations.post(conv, "live")
await world.settle(conv, 1)
assert world.pool.get(conv.external_id) is not None
turns = await world.conversations.turns(conv)
assert [(t["text"], t["reply"], t["failed"]) for t in turns] == [
("two", "done two", False),
("three", "refused", True),
]
assert [t["id"] for t in turns] == [entries[2]["uuid"], entries[7]["uuid"]]
result = await world.conversations.rewind(conv, turns[1]["id"])
assert result.turns == 1 and result.previous_session == sid
moved = result.conversation.session_id
assert moved != sid
assert (await world.conversations.get(conv.external_id)).session_id == moved
assert await world.store.load(world.key(sid)) == entries
kept = [
e["message"]["content"]
for e in await world.store.load(world.key(moved))
if e["type"] in ("user", "assistant")
]
assert len(kept) == 7 and kept[0] == "one"
assert kept[-1] == [{"type": "text", "text": "[Request interrupted by user]"}]
assert world.pool.get(conv.external_id) is None
await world.conversations.run_text_turn(conv, "after", origin="user")
assert conv.session_id == sid
assert ScriptedClient.instances[-1].options.resume == moved
(two,) = await world.conversations.turns(result.conversation)
assert two["text"] == "two"
again = await world.conversations.rewind(conv, two["id"])
assert again.turns == 1 and again.previous_session == moved
assert await world.conversations.turns(again.conversation) == []
first = (await world.store.load(world.key(again.conversation.session_id)))[0]
with pytest.raises(LookupError):
await world.conversations.rewind(conv, first["uuid"])
with pytest.raises(LookupError):
await world.conversations.rewind(conv, str(uuid.uuid4()))
async def test_rewind_waits_for_no_turn(world: World) -> None:
sid, entries = await _three_turns(world)
conv = await world.conversations.create(
kind="master", agent="a", origin="test", session_id=sid
)
ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "busy")
await asyncio.sleep(0.2)
with pytest.raises(RuntimeError, match="mid-turn"):
await world.conversations.rewind(conv, entries[7]["uuid"])
ScriptedClient.hold.set()
await world.settle(conv, 1)
assert (await world.conversations.get(conv.external_id)).session_id == sid
async def test_merge_injects_summary_into_parent(world: World) -> None: async def test_merge_injects_summary_into_parent(world: World) -> None:
sid = str(uuid.uuid4()) sid = str(uuid.uuid4())
await world.store.append( await world.store.append(
+9
View File
@@ -16,6 +16,7 @@ import type {
SearchResponse, SearchResponse,
SessionsResponse, SessionsResponse,
TokenRow, TokenRow,
TurnSummary,
UsageGroup, UsageGroup,
UsageResponse, UsageResponse,
VaultGraph, VaultGraph,
@@ -187,6 +188,14 @@ export class ApiClient {
return this.get(`/api/conversations/${id}/history`); return this.get(`/api/conversations/${id}/history`);
} }
turns(id: string, limit: number): Promise<{ turns: TurnSummary[] }> {
return this.get(`/api/conversations/${id}/turns`, { limit });
}
rewind(id: string, turn: string): Promise<{ turns: number }> {
return this.post(`/api/conversations/${id}/rewind`, { turn });
}
context(id: string, prompt = false): Promise<ContextResponse> { context(id: string, prompt = false): Promise<ContextResponse> {
return this.get(`/api/conversations/${id}/context`, { return this.get(`/api/conversations/${id}/context`, {
prompt: prompt ? 1 : undefined, prompt: prompt ? 1 : undefined,
+8
View File
@@ -113,6 +113,14 @@ export interface HistoryMessage {
ts?: string | null; ts?: string | null;
} }
export interface TurnSummary {
failed: boolean;
id: string;
reply: string;
text: string;
ts: string | null;
}
export interface EntriesPage { export interface EntriesPage {
entries: Record<string, unknown>[]; entries: Record<string, unknown>[];
id: string; id: string;
+7
View File
@@ -109,6 +109,13 @@ export function fmtTime(iso: string | null | undefined): string {
: "–"; : "–";
} }
export function fmtMinute(iso: string | null | undefined): string {
const date = parseDate(iso);
return date
? date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
: "–";
}
export function fmtDateTime(iso: string | null | undefined): string { export function fmtDateTime(iso: string | null | undefined): string {
const date = parseDate(iso); const date = parseDate(iso);
return date return date
+8
View File
@@ -9,6 +9,7 @@ import FileTextIcon from "@lucide/svelte/icons/file-text";
import GitBranchIcon from "@lucide/svelte/icons/git-branch"; import GitBranchIcon from "@lucide/svelte/icons/git-branch";
import GitMergeIcon from "@lucide/svelte/icons/git-merge"; import GitMergeIcon from "@lucide/svelte/icons/git-merge";
import PencilIcon from "@lucide/svelte/icons/pencil"; import PencilIcon from "@lucide/svelte/icons/pencil";
import RewindIcon from "@lucide/svelte/icons/rewind";
import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw"; import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw";
import SendIcon from "@lucide/svelte/icons/send"; import SendIcon from "@lucide/svelte/icons/send";
import type { Component } from "svelte"; import type { Component } from "svelte";
@@ -38,6 +39,7 @@ export interface ActionScope {
onChanged: () => void; onChanged: () => void;
onOpen: (id: string) => void; onOpen: (id: string) => void;
onRename: () => void; onRename: () => void;
onRewind: () => void;
} }
const TELEGRAM = "telegram"; const TELEGRAM = "telegram";
@@ -103,6 +105,12 @@ export function conversationActions(
label: "Branch…", label: "Branch…",
run: scope.onBranch, run: scope.onBranch,
}); });
actions.push({
disabled: !info.session_id,
icon: RewindIcon,
label: "Rewind…",
run: scope.onRewind,
});
actions.push({ actions.push({
checked: remembers, checked: remembers,
gap: true, gap: true,
@@ -13,6 +13,7 @@
import { usePanelHost } from "./host"; import { usePanelHost } from "./host";
import MenuItems, { type MenuKit } from "./menu-items.svelte"; import MenuItems, { type MenuKit } from "./menu-items.svelte";
import RenameDialog from "./rename-dialog.svelte"; import RenameDialog from "./rename-dialog.svelte";
import RewindDialog from "./rewind-dialog.svelte";
let { let {
client, client,
@@ -47,6 +48,7 @@
let failure = $state<string | null>(null); let failure = $state<string | null>(null);
let branchOpen = $state(false); let branchOpen = $state(false);
let renameOpen = $state(false); let renameOpen = $state(false);
let rewindOpen = $state(false);
const loaded = $derived(info ?? fetched); const loaded = $derived(info ?? fetched);
@@ -77,6 +79,9 @@
onRename: () => { onRename: () => {
renameOpen = true; renameOpen = true;
}, },
onRewind: () => {
rewindOpen = true;
},
}); });
const actions = $derived(loaded ? conversationActions(loaded, scope) : []); const actions = $derived(loaded ? conversationActions(loaded, scope) : []);
</script> </script>
@@ -141,4 +146,5 @@
bind:open={branchOpen} bind:open={branchOpen}
/> />
<RenameDialog {client} info={loaded} {onChanged} bind:open={renameOpen} /> <RenameDialog {client} info={loaded} {onChanged} bind:open={renameOpen} />
<RewindDialog {client} info={loaded} {onChanged} bind:open={rewindOpen} />
{/if} {/if}
+210
View File
@@ -0,0 +1,210 @@
<script lang="ts">
import { tick, untrack } from "svelte";
import { toast } from "svelte-sonner";
import type { ApiClient } from "$lib/api/client";
import type { ConversationSummary, TurnSummary } from "$lib/api/types";
import EmptyState from "$lib/components/empty-state.svelte";
import ErrorNote from "$lib/components/error-note.svelte";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { Skeleton } from "$lib/components/ui/skeleton";
import { clip, fmtDateTime, fmtMinute, shortId } from "$lib/format";
import { cn } from "$lib/utils";
let {
client,
info,
open = $bindable(false),
onChanged,
}: {
client: ApiClient;
info: Pick<ConversationSummary, "id" | "kind" | "running_turn" | "title">;
open?: boolean;
onChanged: () => void;
} = $props();
const LIMIT = 30;
const LINE = 160;
let turns = $state<TurnSummary[] | null>(null);
let failure = $state<string | null>(null);
let picked = $state<number | null>(null);
let busy = $state(false);
let list = $state<HTMLDivElement | null>(null);
const name = $derived(info.title || `${info.kind} ${shortId(info.id)}`);
const count = $derived(
turns === null || picked === null ? 0 : turns.length - picked
);
function plural(n: number): string {
return n === 1 ? "1 turn" : `${n} turns`;
}
function brokenTail(rows: TurnSummary[]): number | null {
let at = rows.length;
while (at > 0 && rows[at - 1].failed) {
at -= 1;
}
return at < rows.length ? at : null;
}
async function load() {
turns = null;
failure = null;
picked = null;
try {
({ turns } = await client.turns(info.id, LIMIT));
picked = brokenTail(turns);
await tick();
if (list) {
list.scrollTop = list.scrollHeight;
list
.querySelector(":checked")
?.closest("label")
?.scrollIntoView({ block: "nearest" });
}
} catch (error) {
failure = error instanceof Error ? error.message : String(error);
}
}
async function rewind() {
const turn = turns?.[picked ?? -1];
if (!turn) {
return;
}
busy = true;
try {
const result = await client.rewind(info.id, turn.id);
open = false;
toast.success(`Cut ${plural(result.turns)}`);
onChanged();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
busy = false;
}
}
$effect(() => {
if (open) {
untrack(() => load());
}
});
</script>
<Dialog.Root bind:open>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Rewind {name}</Dialog.Title>
<Dialog.Description>
Pick the turn to cut from. The model forgets it and every turn after it.
The old session stays in the database.
</Dialog.Description>
</Dialog.Header>
<div class="flex min-w-0 flex-col gap-3">
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if turns === null}
<div class="flex flex-col gap-3 py-2">
{#each [0, 1, 2] as row (row)}
<div class="flex flex-col gap-1.5">
<Skeleton class="h-3.5 w-3/5" />
<Skeleton class="h-3 w-4/5" />
</div>
{/each}
</div>
{:else if turns.length === 0}
<EmptyState
hint="The first turn of a thread stays, and this thread has no other."
title="Nothing to cut"
/>
{:else}
<div
aria-label="Turn to cut from"
class="-mx-2 grid max-h-[min(22rem,50dvh)] grid-cols-[auto_minmax(0,1fr)] gap-x-3 overflow-y-auto rounded-md pt-2"
role="radiogroup"
bind:this={list}
>
{#each turns as turn, index (turn.id)}
{@const gone = picked !== null && index >= picked}
<label
class={cn(
"relative col-span-2 grid cursor-pointer grid-cols-subgrid items-baseline border-t px-2 py-2 first:border-t-transparent has-focus-visible:ring-1 has-focus-visible:ring-ring has-focus-visible:ring-inset",
gone ? "bg-destructive/5" : "row-hover",
index === picked &&
"border-t-destructive/40 first:border-t-destructive/40"
)}
>
<input
checked={index === picked}
class="sr-only"
name="rewind-turn"
onchange={() => {
picked = index;
}}
type="radio"
>
<span
class="tabular text-muted-foreground text-xs"
title={fmtDateTime(turn.ts)}
>
{fmtMinute(turn.ts)}
</span>
<span class="flex min-w-0 flex-col">
<span
class={cn(
"truncate font-medium",
gone && "text-muted-foreground"
)}
>
{clip(turn.text, LINE) || "empty prompt"}
</span>
<span
class={cn(
"truncate text-xs",
turn.failed ? "text-destructive" : "text-muted-foreground"
)}
>
{clip(turn.reply, LINE) ||
(turn.failed ? "failed" : "no reply")}
</span>
</span>
{#if index === picked}
<span
class="-top-2 absolute right-2 rounded-md bg-[color-mix(in_oklab,var(--destructive)_12%,var(--popover))] px-1.5 font-medium text-destructive text-xs leading-4"
>
cut from here
</span>
{/if}
</label>
{/each}
</div>
{/if}
{#if info.running_turn}
<p class="text-warn text-xs">A turn is running. Interrupt it first.</p>
{/if}
</div>
<Dialog.Footer>
<Button
onclick={() => {
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
open = false;
}}
variant="ghost"
>
Cancel
</Button>
{#if turns?.length}
<Button
disabled={busy || count === 0 || Boolean(info.running_turn)}
onclick={rewind}
variant="destructive"
>
{count === 0 ? "Cut" : `Cut ${plural(count)}`}
</Button>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>