diff --git a/frontend/ui/src/components/icon.tsx b/frontend/ui/src/components/icon.tsx index 544c6abd..4db633b3 100644 --- a/frontend/ui/src/components/icon.tsx +++ b/frontend/ui/src/components/icon.tsx @@ -38,6 +38,7 @@ const icons = { "window-cursor": ``, task: ``, stop: ``, + undo: ``, "layout-left": ``, "layout-left-partial": ``, "layout-left-full": ``, diff --git a/frontend/ui/src/components/message-part.css b/frontend/ui/src/components/message-part.css index 26ed68a1..938d5980 100644 --- a/frontend/ui/src/components/message-part.css +++ b/frontend/ui/src/components/message-part.css @@ -93,6 +93,9 @@ position: absolute; top: 7px; right: 7px; + display: flex; + align-items: center; + gap: 4px; opacity: 0; transition: opacity 0.15s ease; } diff --git a/frontend/ui/src/components/message-part.tsx b/frontend/ui/src/components/message-part.tsx index 87e8103c..fe0e3436 100644 --- a/frontend/ui/src/components/message-part.tsx +++ b/frontend/ui/src/components/message-part.tsx @@ -93,6 +93,9 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { export interface MessageProps { message: MessageType parts: PartType[] + /** When set, user messages render an undo action that reverts the + * conversation (and file changes) back to this message. */ + onRevert?: () => void } export interface MessagePartProps { @@ -283,7 +286,9 @@ export function Message(props: MessageProps) { return ( - {(userMessage) => } + {(userMessage) => ( + + )} {(assistantMessage) => ( @@ -307,7 +312,7 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part return {(part) => } } -export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { +export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; onRevert?: () => void }) { const dialog = useDialog() const i18n = useI18n() const [copied, setCopied] = createSignal(false) @@ -424,6 +429,20 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
+ + + e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + props.onRevert?.() + }} + aria-label={i18n.t("ui.message.revert")} + /> + + void onUserInteracted?: () => void + onRevertMessage?: (messageID: string) => void hideTools?: string[] classes?: { root?: string @@ -572,7 +573,13 @@ export function SessionTurn(
{/* 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([]) + const [inflight, setInflight] = createSignal(false) + createEffect( + on( + () => params.id, + () => setQueue([]), + { defer: true }, + ), + ) const [focused, setFocused] = createSignal(false) const [dragOver, setDragOver] = createSignal(false) const [attachments, setAttachments] = createSignal([]) @@ -696,6 +723,41 @@ export function Composer(): JSX.Element { const trimmed = text().trim() const atts = attachments() if ((!trimmed && atts.length === 0) || submitting()) return + const chosen = model() + if (!chosen) { + toast.error( + "no model selected", + `Connect a provider key at ${BYOK_URL} to enable a model.`, + ) + return + } + const payload: QueuedPrompt = { + id: Identifier.ascending("message"), + text: trimmed, + attachments: atts, + agent: agent(), + model: chosen, + variant: models.variant.get(chosen), + fast: isGpt55(chosen.modelID) ? fast() : undefined, + } + // Clear the input immediately in both paths so typing can continue. + setText("") + setAttachments([]) + if (textareaRef) textareaRef.style.height = "auto" + + // Mid-turn sends queue; the drain effect below fires them when idle. + if (isWorking() || inflight()) { + setQueue((q) => [...q, payload]) + return + } + await dispatch(payload) + } + + const dispatch = async (p: QueuedPrompt) => { + if (submitting()) { + setQueue((q) => [p, ...q]) + return + } setSubmitting(true) try { let sessionID = sessionPending() @@ -712,18 +774,9 @@ export function Composer(): JSX.Element { navigate(`/${params.dir}/session/${sessionID}`, { replace: true }) } - const chosen = model() - if (!chosen) { - toast.error( - "no model selected", - `Connect a provider key at ${BYOK_URL} to enable a model.`, - ) - return - } - const agentName = agent() const messageID = Identifier.ascending("message") - const filePartsBase = atts.map((a) => ({ + const filePartsBase = p.attachments.map((a) => ({ id: Identifier.ascending("part"), type: "file" as const, mime: a.mime, @@ -735,10 +788,10 @@ export function Composer(): JSX.Element { // so the agent persists each attachment to .context/. // The agent's `write` tool handles binary via base64 from the // data URL we sent. - const userText = trimmed || (atts.length > 0 ? "(see attachments)" : "") + const userText = p.text || (p.attachments.length > 0 ? "(see attachments)" : "") const guidance = - atts.length > 0 - ? `\n\n---\nAttachments: ${atts + p.attachments.length > 0 + ? `\n\n---\nAttachments: ${p.attachments .map((a) => safeFilename(a.filename)) .join(", ")}.\nIf they aren't already there, save each attachment to \`.context/\` at the project root (create the directory if missing). For text-like files use the write tool; for binaries decode the data URL with bash. Treat \`.context/\` as the durable scratchpad for files dropped into chat.` : "" @@ -754,28 +807,33 @@ export function Composer(): JSX.Element { sync.session.addOptimisticMessage({ sessionID, messageID, - agent: agentName, - model: chosen, - parts: promptParts.map((p) => ({ ...p, sessionID, messageID }) as any), + agent: p.agent, + model: p.model, + parts: promptParts.map((part) => ({ ...part, sessionID, messageID }) as any), }) - // Clear input + attachments now that they're persisted. - setText("") - setAttachments([]) - if (textareaRef) textareaRef.style.height = "auto" + // Fire-and-forget: session.prompt resolves only when the whole turn + // completes, so awaiting it here is what used to freeze the composer + // for the entire generation. `inflight` tracks the turn instead. + setInflight(true) + sdk.client.session + .prompt({ + sessionID, + messageID, + directory: sync.project?.worktree ?? sync.data.path.directory, + model: p.model, + agent: p.agent, + variant: p.variant, + fast: p.fast, + parts: promptParts, + } as any) + .catch((e: any) => { + console.error("session.prompt failed", e) + toast.error("send failed", e?.message ?? String(e)) + }) + .finally(() => setInflight(false)) - await sdk.client.session.prompt({ - sessionID, - messageID, - directory: sync.project?.worktree ?? sync.data.path.directory, - model: chosen, - agent: agentName, - variant: models.variant.get(chosen), - fast: isGpt55(chosen.modelID) ? fast() : undefined, - parts: promptParts, - } as any) - - models.recent.push(chosen) + models.recent.push(p.model) } catch (e: any) { console.error("session.prompt failed", e) toast.error("send failed", e?.message ?? String(e)) @@ -784,6 +842,16 @@ export function Composer(): JSX.Element { } } + // Drain the queue: whenever the session is idle and nothing is in flight, + // send the next queued prompt. + createEffect(() => { + if (isWorking() || inflight() || submitting()) return + const next = queue()[0] + if (!next) return + setQueue((q) => q.slice(1)) + void dispatch(next) + }) + const onKey = (e: KeyboardEvent) => { if (slashOpen() && slashItems().length > 0) { const len = slashItems().length @@ -869,6 +937,82 @@ export function Composer(): JSX.Element { transition: "background 120ms ease, box-shadow 120ms ease, border-color 120ms ease", }} > + 0}> +
+ + queued · {queue().length} + + + {(q) => ( + + { + if (text().trim().length > 0) { + toast.info("input not empty", "clear the input to pull a queued message back") + return + } + setQueue((qs) => qs.filter((x) => x.id !== q.id)) + setAttachments(q.attachments) + grow(q.text) + }} + > + {q.text || "(attachments)"} + + + + )} + +
+
0}>
- + - ↵ to send · ⇧↵ newline + {isWorking() || inflight() ? "↵ to queue · ⇧↵ newline" : "↵ to send · ⇧↵ newline"} - void submit()} - disabled={ - (text().trim().length === 0 && attachments().length === 0) || submitting() - } - type="button" - style={{ - all: "unset", - "box-sizing": "border-box", - cursor: - (text().trim() || attachments().length > 0) && !submitting() - ? "pointer" - : "not-allowed", - height: "28px", - padding: "0 12px", - display: "inline-flex", - "align-items": "center", - gap: "5px", - "border-radius": "4px", - background: - (text().trim().length === 0 && attachments().length === 0) || submitting() - ? "var(--color-bg-elevated)" - : "var(--color-accent)", - color: - (text().trim().length === 0 && attachments().length === 0) || submitting() - ? "var(--color-text-faint)" - : "var(--color-on-accent)", - "font-family": FONT_MONO, - "font-size": "12px", - transition: "all 120ms ease", - }} - > - - send - - } - > + 0 || attachments().length > 0}> + + + +