{/* User Message */}
-
+ props.onRevertMessage?.(msg().id) : undefined
+ }
+ />
{/* Trigger (sticky) */}
diff --git a/frontend/ui/src/i18n/ar.ts b/frontend/ui/src/i18n/ar.ts
index fc99bdac..808dde39 100644
--- a/frontend/ui/src/i18n/ar.ts
+++ b/frontend/ui/src/i18n/ar.ts
@@ -87,6 +87,7 @@ export const dict = {
"ui.message.collapse": "طي الرسالة",
"ui.message.copy": "نسخ",
"ui.message.copied": "تم النسخ!",
+ "ui.message.revert": "التراجع من هنا",
"ui.message.attachment.alt": "مرفق",
"ui.patch.action.deleted": "محذوف",
diff --git a/frontend/ui/src/i18n/br.ts b/frontend/ui/src/i18n/br.ts
index fdec9138..bb61de69 100644
--- a/frontend/ui/src/i18n/br.ts
+++ b/frontend/ui/src/i18n/br.ts
@@ -87,6 +87,7 @@ export const dict = {
"ui.message.collapse": "Recolher mensagem",
"ui.message.copy": "Copiar",
"ui.message.copied": "Copiado!",
+ "ui.message.revert": "Desfazer a partir daqui",
"ui.message.attachment.alt": "anexo",
"ui.patch.action.deleted": "Excluído",
diff --git a/frontend/ui/src/i18n/da.ts b/frontend/ui/src/i18n/da.ts
index fbe34e03..bd94b555 100644
--- a/frontend/ui/src/i18n/da.ts
+++ b/frontend/ui/src/i18n/da.ts
@@ -87,6 +87,7 @@ export const dict = {
"ui.message.collapse": "Skjul besked",
"ui.message.copy": "Kopier",
"ui.message.copied": "Kopieret!",
+ "ui.message.revert": "Fortryd herfra",
"ui.message.attachment.alt": "vedhæftning",
"ui.patch.action.deleted": "Slettet",
diff --git a/frontend/ui/src/i18n/en.ts b/frontend/ui/src/i18n/en.ts
index 8c0f09cb..fc36f4ac 100644
--- a/frontend/ui/src/i18n/en.ts
+++ b/frontend/ui/src/i18n/en.ts
@@ -88,6 +88,7 @@ export const dict = {
"ui.message.collapse": "Collapse message",
"ui.message.copy": "Copy",
"ui.message.copied": "Copied!",
+ "ui.message.revert": "Undo from here",
"ui.message.attachment.alt": "attachment",
"ui.patch.action.deleted": "Deleted",
diff --git a/frontend/ui/src/i18n/es.ts b/frontend/ui/src/i18n/es.ts
index 3f71a98a..18fab078 100644
--- a/frontend/ui/src/i18n/es.ts
+++ b/frontend/ui/src/i18n/es.ts
@@ -87,6 +87,7 @@ export const dict = {
"ui.message.collapse": "Colapsar mensaje",
"ui.message.copy": "Copiar",
"ui.message.copied": "¡Copiado!",
+ "ui.message.revert": "Deshacer desde aquí",
"ui.message.attachment.alt": "adjunto",
"ui.patch.action.deleted": "Eliminado",
diff --git a/frontend/ui/src/i18n/fr.ts b/frontend/ui/src/i18n/fr.ts
index 0ec70509..2d8106a0 100644
--- a/frontend/ui/src/i18n/fr.ts
+++ b/frontend/ui/src/i18n/fr.ts
@@ -87,6 +87,7 @@ export const dict = {
"ui.message.collapse": "Réduire le message",
"ui.message.copy": "Copier",
"ui.message.copied": "Copié !",
+ "ui.message.revert": "Annuler à partir d'ici",
"ui.message.attachment.alt": "pièce jointe",
"ui.patch.action.deleted": "Supprimé",
diff --git a/frontend/ui/src/i18n/ja.ts b/frontend/ui/src/i18n/ja.ts
index fd3f24ab..3530c085 100644
--- a/frontend/ui/src/i18n/ja.ts
+++ b/frontend/ui/src/i18n/ja.ts
@@ -87,6 +87,7 @@ export const dict = {
"ui.message.collapse": "メッセージを折りたたむ",
"ui.message.copy": "コピー",
"ui.message.copied": "コピーしました!",
+ "ui.message.revert": "ここから元に戻す",
"ui.message.attachment.alt": "添付ファイル",
"ui.patch.action.deleted": "削除済み",
diff --git a/frontend/ui/src/i18n/ko.ts b/frontend/ui/src/i18n/ko.ts
index e419f730..e9fd74c9 100644
--- a/frontend/ui/src/i18n/ko.ts
+++ b/frontend/ui/src/i18n/ko.ts
@@ -87,6 +87,7 @@ export const dict = {
"ui.message.collapse": "메시지 접기",
"ui.message.copy": "복사",
"ui.message.copied": "복사됨!",
+ "ui.message.revert": "여기서부터 실행 취소",
"ui.message.attachment.alt": "첨부 파일",
"ui.patch.action.deleted": "삭제됨",
diff --git a/frontend/ui/src/i18n/no.ts b/frontend/ui/src/i18n/no.ts
index 4433b811..886451b2 100644
--- a/frontend/ui/src/i18n/no.ts
+++ b/frontend/ui/src/i18n/no.ts
@@ -90,6 +90,7 @@ export const dict: Record
= {
"ui.message.collapse": "Skjul melding",
"ui.message.copy": "Kopier",
"ui.message.copied": "Kopiert!",
+ "ui.message.revert": "Angre herfra",
"ui.message.attachment.alt": "vedlegg",
"ui.patch.action.deleted": "Slettet",
diff --git a/frontend/ui/src/i18n/pl.ts b/frontend/ui/src/i18n/pl.ts
index efe4bf6c..841800be 100644
--- a/frontend/ui/src/i18n/pl.ts
+++ b/frontend/ui/src/i18n/pl.ts
@@ -87,6 +87,7 @@ export const dict = {
"ui.message.collapse": "Zwiń wiadomość",
"ui.message.copy": "Kopiuj",
"ui.message.copied": "Skopiowano!",
+ "ui.message.revert": "Cofnij od tego miejsca",
"ui.message.attachment.alt": "załącznik",
"ui.patch.action.deleted": "Usunięto",
diff --git a/frontend/ui/src/i18n/ru.ts b/frontend/ui/src/i18n/ru.ts
index 60e63455..f2358ae0 100644
--- a/frontend/ui/src/i18n/ru.ts
+++ b/frontend/ui/src/i18n/ru.ts
@@ -87,6 +87,7 @@ export const dict = {
"ui.message.collapse": "Свернуть сообщение",
"ui.message.copy": "Копировать",
"ui.message.copied": "Скопировано!",
+ "ui.message.revert": "Отменить с этого места",
"ui.message.attachment.alt": "вложение",
"ui.patch.action.deleted": "Удалено",
diff --git a/frontend/ui/src/i18n/th.ts b/frontend/ui/src/i18n/th.ts
index 4c74e9ff..5520ef98 100644
--- a/frontend/ui/src/i18n/th.ts
+++ b/frontend/ui/src/i18n/th.ts
@@ -88,6 +88,7 @@ export const dict = {
"ui.message.collapse": "ย่อข้อความ",
"ui.message.copy": "คัดลอก",
"ui.message.copied": "คัดลอกแล้ว!",
+ "ui.message.revert": "เลิกทำจากตรงนี้",
"ui.message.attachment.alt": "ไฟล์แนบ",
"ui.patch.action.deleted": "ลบ",
diff --git a/frontend/workspace/src/pages/session.tsx b/frontend/workspace/src/pages/session.tsx
index 5efc9244..10533b50 100644
--- a/frontend/workspace/src/pages/session.tsx
+++ b/frontend/workspace/src/pages/session.tsx
@@ -28,6 +28,7 @@ import { FONT_MONO, FONT_SANS, FONT_SERIF, sectionTitle } from "@/styles/tokens"
import { uiStore } from "@/thesis/store/ui"
import { useGlobalKeys } from "@/thesis/useGlobalKeys"
import { useDialog } from "@synsci/ui/context/dialog"
+import { confirmDialog } from "@/thesis/dialogs"
import { DialogSettings } from "@/components/dialog-settings"
import { DisconnectedPanel } from "@/thesis/DisconnectedPanel"
import { CommandPalette } from "@/thesis/CommandPalette"
@@ -182,7 +183,50 @@ export default function Page(): JSX.Element {
// each of the (often hundreds of) assistant messages paint an empty turn plus
// a divider, which stacked up as faint horizontal lines down the chat and
// bloated the DOM (slowing the reflow when the right pane opens).
- const turnMessages = createMemo(() => messages().filter((m) => m.role === "user"))
+ // When the session is in a reverted state, turns at or past the revert point
+ // stay hidden until the user restores them or sends a new message (which
+ // makes the revert permanent server-side).
+ const activeSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+ const revertInfo = createMemo(() => activeSession()?.revert)
+ const turnMessages = createMemo(() => {
+ const revertID = revertInfo()?.messageID
+ return messages().filter((m) => m.role === "user" && (!revertID || m.id < revertID))
+ })
+ const revertedCount = createMemo(() => {
+ const revertID = revertInfo()?.messageID
+ if (!revertID) return 0
+ return messages().filter((m) => m.role === "user" && m.id >= revertID).length
+ })
+
+ const revertTo = async (messageID: string) => {
+ const id = params.id
+ if (!id) return
+ const ok = await confirmDialog(dialog, {
+ title: "Undo from here?",
+ message:
+ "Hides this message and everything after it, and rolls back the file changes they made. You can restore until you send the next message.",
+ confirmLabel: "undo",
+ danger: true,
+ })
+ if (!ok) return
+ try {
+ await sync.session.revert(id, messageID)
+ toast.success("reverted", "files rolled back. send a message to continue from here")
+ } catch (e: any) {
+ toast.error("undo failed", e?.message ?? String(e))
+ }
+ }
+
+ const restoreRevert = async () => {
+ const id = params.id
+ if (!id) return
+ try {
+ await sync.session.unrevert(id)
+ toast.success("messages restored")
+ } catch (e: any) {
+ toast.error("restore failed", e?.message ?? String(e))
+ }
+ }
const [stepsExpanded, setStepsExpanded] = createSignal>({})
const toggleSteps = (id: string) =>
@@ -380,6 +424,7 @@ export default function Page(): JSX.Element {
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={stepsExpanded()[message.id] ?? false}
onStepsExpandedToggle={() => toggleSteps(message.id)}
+ onRevertMessage={(id) => void revertTo(id)}
hideTools={["task"]}
classes={{
root: "min-w-0 w-full relative",
@@ -408,6 +453,46 @@ export default function Page(): JSX.Element {
+
+
+
+
+ Conversation reverted. {revertedCount()} turn{revertedCount() === 1 ? "" : "s"} hidden and
+ file changes rolled back. Sending a new message makes this permanent.
+
+
+
+
+
+
@@ -924,13 +1009,19 @@ function ChatWelcome(): JSX.Element {
cursor: "pointer",
padding: "5px 12px",
"border-radius": "4px",
- border: uiStore.agent() === m.name ? "1px solid var(--color-text)" : "1px solid var(--color-border)",
+ border: uiStore.agent() === m.name ? "1px solid var(--color-border)" : "1px solid transparent",
background: uiStore.agent() === m.name ? "var(--color-accent-subtle)" : "transparent",
"font-family": FONT_MONO,
"font-size": "11px",
color: uiStore.agent() === m.name ? "var(--color-text)" : "var(--color-text-muted)",
transition: "border-color 120ms ease, background 120ms ease, color 120ms ease",
}}
+ onMouseEnter={(e) => {
+ if (uiStore.agent() !== m.name) e.currentTarget.style.background = "var(--color-bg-elevated)"
+ }}
+ onMouseLeave={(e) => {
+ if (uiStore.agent() !== m.name) e.currentTarget.style.background = "transparent"
+ }}
>
{m.name}
diff --git a/frontend/workspace/src/styles/thesis.css b/frontend/workspace/src/styles/thesis.css
index 2d170d65..8a7c7b51 100644
--- a/frontend/workspace/src/styles/thesis.css
+++ b/frontend/workspace/src/styles/thesis.css
@@ -164,16 +164,13 @@ html[data-color-scheme="dark"] {
}
/* ==========================================================
- Base — softly textured ambient mesh.
+ Base — flat, quiet canvas. (The colored ambient mesh that used
+ to sit here read as a random gradient bleeding around the chat
+ and message boxes, so it's gone.)
========================================================== */
.thesis-root {
position: relative;
background: var(--color-bg);
- background-image:
- radial-gradient(at 8% -2%, rgba(99, 102, 241, 0.075), transparent 38%),
- radial-gradient(at 98% 2%, rgba(249, 115, 22, 0.06), transparent 36%),
- radial-gradient(at 50% 100%, rgba(34, 197, 94, 0.05), transparent 42%),
- radial-gradient(at 0% 100%, rgba(59, 130, 246, 0.05), transparent 36%);
color: var(--color-text);
font-family: var(--font-sans);
letter-spacing: -0.011em;
@@ -182,19 +179,10 @@ html[data-color-scheme="dark"] {
font-feature-settings: "ss01", "ss02";
}
-.dark .thesis-root,
-html[data-color-scheme="dark"] .thesis-root {
- background-image:
- radial-gradient(at 8% -2%, rgba(99, 102, 241, 0.16), transparent 38%),
- radial-gradient(at 98% 2%, rgba(249, 115, 22, 0.10), transparent 36%),
- radial-gradient(at 50% 100%, rgba(34, 197, 94, 0.08), transparent 42%),
- radial-gradient(at 0% 100%, rgba(59, 130, 246, 0.10), transparent 36%);
-}
-
-/* The ambient mesh above is kept; the 40px ruled grid overlay that used to
- live in .thesis-root::before was removed — it read as distracting horizontal
- lines in empty areas (e.g. the chat), and its full-viewport masked repaint
- was costly during layout changes like opening the right pane. */
+/* The 40px ruled grid overlay that used to live in .thesis-root::before was
+ removed — it read as distracting horizontal lines in empty areas (e.g. the
+ chat), and its full-viewport masked repaint was costly during layout changes
+ like opening the right pane. */
.thesis-root > * {
position: relative;
diff --git a/frontend/workspace/src/thesis/Composer.tsx b/frontend/workspace/src/thesis/Composer.tsx
index 05b54e12..b7946762 100644
--- a/frontend/workspace/src/thesis/Composer.tsx
+++ b/frontend/workspace/src/thesis/Composer.tsx
@@ -12,6 +12,7 @@ import {
IconSearch,
IconSparkles,
IconStop,
+ IconX,
} from "@/thesis/shared/Icon"
import { AsciiSpinner } from "@/thesis/shared/AsciiSpinner"
import { toast } from "@/thesis/Toast"
@@ -208,6 +209,32 @@ export function Composer(): JSX.Element {
// Highlighted row index for keyboard nav inside the model search.
const [modelIndex, setModelIndex] = createSignal(0)
const [submitting, setSubmitting] = createSignal(false)
+
+ // ── Prompt queue ─────────────────────────────────────────────────────────
+ // The composer never locks while the agent is streaming. Sends that arrive
+ // mid-turn are queued (with the agent/model/effort chosen at enqueue time)
+ // and dispatched in order the moment the session goes idle. `inflight`
+ // covers the whole server turn (session.prompt resolves when the turn
+ // completes), which also bridges the SSE gap before session_status flips
+ // to non-idle — without it two queued prompts could race out together.
+ type QueuedPrompt = {
+ id: string
+ text: string
+ attachments: Attachment[]
+ agent: string
+ model: ModelKey
+ variant: string | undefined
+ fast: boolean | undefined
+ }
+ const [queue, setQueue] = createSignal