From 0e9b20f27a0c0ba4ef084244f10504a590d650fd Mon Sep 17 00:00:00 2001 From: forthfate Date: Sat, 26 Sep 2026 16:40:49 +0900 Subject: [PATCH 1/5] Persist Orbit assistant chat history --- frontend/src/components/chat-assistant.tsx | 69 ++++++++++++++++++---- frontend/src/locales/languages/en.json | 3 +- frontend/src/locales/languages/ja.json | 3 +- frontend/src/locales/languages/ko.json | 3 +- 4 files changed, 62 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/chat-assistant.tsx b/frontend/src/components/chat-assistant.tsx index 6edd9c9..1cbbe5f 100644 --- a/frontend/src/components/chat-assistant.tsx +++ b/frontend/src/components/chat-assistant.tsx @@ -4,6 +4,7 @@ import { Send, Settings, SquareTerminal, + Trash2, X, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -69,9 +70,12 @@ type ChatAssistantCopy = { message: string; placeholder: string; send: string; + clearHistory: string; }; const positionKey = "orbit.chat.position"; const windowPositionKey = "orbit.chat.window.position"; +const messagesKey = "orbit.chat.messages.v1"; +const maxStoredMessages = 100; const defaultToolSettings: ToolSettings = { workspace_root: "", file_read_enabled: true, @@ -133,6 +137,23 @@ const initialWindowPosition = (): Position | null => { return null; }; +const initialMessages = (): Message[] => { + try { + const saved = JSON.parse(localStorage.getItem(messagesKey) ?? "[]"); + if (!Array.isArray(saved)) return []; + return saved + .filter( + (message): message is Message => + message && + (message.role === "user" || message.role === "assistant") && + typeof message.content === "string", + ) + .slice(-maxStoredMessages); + } catch { + return []; + } +}; + export function ChatAssistant() { const { sessionId, handleCommand } = useAssistantUiBridge(); const copy = localeMessages( @@ -140,7 +161,7 @@ export function ChatAssistant() { "chatAssistant", ); const [open, setOpen] = useState(false), - [messages, setMessages] = useState([]), + [messages, setMessages] = useState(initialMessages), [draft, setDraft] = useState(""), [sending, setSending] = useState(false), [activityPhase, setActivityPhase] = useState<"thinking" | "working">("thinking"), @@ -171,6 +192,12 @@ export function ChatAssistant() { const messageList = useRef(null); const chatWindow = useRef(null); const hasUsedTool = useRef(false); + const clearHistory = () => { + setMessages([]); + localStorage.removeItem(messagesKey); + }; + const appendMessage = (message: Message) => + setMessages((current) => [...current, message].slice(-maxStoredMessages)); useEffect(() => { messageList.current?.scrollTo({ top: messageList.current.scrollHeight }); }, [messages, sending, activityLines]); @@ -189,6 +216,14 @@ export function ChatAssistant() { if (windowPosition) localStorage.setItem(windowPositionKey, JSON.stringify(windowPosition)); }, [windowPosition]); + useEffect(() => { + try { + if (messages.length) localStorage.setItem(messagesKey, JSON.stringify(messages)); + else localStorage.removeItem(messagesKey); + } catch { + // A full or unavailable browser store must not prevent chatting. + } + }, [messages]); useEffect(() => { if (open) api("/api/application-settings") @@ -248,9 +283,14 @@ export function ChatAssistant() { const send = async () => { const content = draft.trim(); if (!content || sending) return; + if (content.toLowerCase() === "/clear") { + setDraft(""); + clearHistory(); + return; + } const history = messages.slice(-12); setDraft(""); - setMessages((current) => [...current, { role: "user", content }]); + appendMessage({ role: "user", content }); setSending(true); setActivityPhase("thinking"); setActivityLines([]); @@ -312,18 +352,12 @@ export function ChatAssistant() { if (done) break; } if (!answer) throw new Error(copy.requestFailed); - setMessages((current) => [ - ...current, - { role: "assistant", content: answer }, - ]); + appendMessage({ role: "assistant", content: answer }); } catch (error) { - setMessages((current) => [ - ...current, - { - role: "assistant", - content: error instanceof Error ? error.message : copy.requestFailed, - }, - ]); + appendMessage({ + role: "assistant", + content: error instanceof Error ? error.message : copy.requestFailed, + }); } finally { setSending(false); } @@ -447,6 +481,15 @@ export function ChatAssistant() { > + @@ -506,6 +511,26 @@ export function ChatAssistant() { > {toolSummary} + {clearConfirmationOpen && ( +
+
+ {copy.clearHistory} +

{copy.clearHistoryPrompt}

+
+ + +
+
+
+ )}
{messages.length === 0 && (

{copy.empty}

diff --git a/frontend/src/locales/languages/en.json b/frontend/src/locales/languages/en.json index 7205fab..6402eb3 100644 --- a/frontend/src/locales/languages/en.json +++ b/frontend/src/locales/languages/en.json @@ -101,7 +101,10 @@ "message": "Message", "placeholder": "Message Orbit…", "send": "Send message", - "clearHistory": "Clear chat history" + "clearHistory": "Clear chat history", + "clearHistoryPrompt": "Delete every message stored in this browser? This cannot be undone.", + "cancel": "Cancel", + "confirmClearHistory": "Clear history" }, "appMeta": { "title": "OpenOrbit", diff --git a/frontend/src/locales/languages/ja.json b/frontend/src/locales/languages/ja.json index 630e21d..8612fa3 100644 --- a/frontend/src/locales/languages/ja.json +++ b/frontend/src/locales/languages/ja.json @@ -95,7 +95,10 @@ "message": "メッセージ", "placeholder": "Orbit にメッセージ…", "send": "メッセージを送信", - "clearHistory": "チャット履歴を削除" + "clearHistory": "チャット履歴を削除", + "clearHistoryPrompt": "このブラウザーに保存されたすべての会話を削除しますか?この操作は元に戻せません。", + "cancel": "キャンセル", + "confirmClearHistory": "履歴を削除" }, "appMeta": { "title": "OpenOrbit", diff --git a/frontend/src/locales/languages/ko.json b/frontend/src/locales/languages/ko.json index c6d5c11..6ea541c 100644 --- a/frontend/src/locales/languages/ko.json +++ b/frontend/src/locales/languages/ko.json @@ -100,7 +100,10 @@ "message": "메시지", "placeholder": "Orbit에 메시지 보내기…", "send": "메시지 전송", - "clearHistory": "대화 기록 삭제" + "clearHistory": "대화 기록 삭제", + "clearHistoryPrompt": "이 브라우저에 저장된 모든 대화를 삭제할까요? 이 작업은 되돌릴 수 없습니다.", + "cancel": "취소", + "confirmClearHistory": "기록 삭제" }, "appMeta": { "title": "OpenOrbit", diff --git a/frontend/src/theme-overrides.css b/frontend/src/theme-overrides.css index 677ae3d..523bca0 100644 --- a/frontend/src/theme-overrides.css +++ b/frontend/src/theme-overrides.css @@ -96,7 +96,7 @@ footer { display:flex; align-items:center; justify-content:space-between; margin .chat-tools-modal { width:min(430px,calc(100vw - 32px)); }.chat-tools-modal-summary { margin:0; padding:9px 10px; color:var(--muted); border:1px solid var(--line); border-radius:7px; background:var(--surface-raised); font-size:11px; }.chat-tool-modal-toggle { display:flex; align-items:center; justify-content:space-between; padding:10px 0; border-bottom:1px solid var(--line); color:var(--text); }.chat-tool-modal-toggle:last-of-type { border-bottom:0; } .assistant-terminal { z-index:0; grid-row:2 / 5; min-height:0; padding:10px; overflow:hidden; background:#101614; }.assistant-terminal .xterm { height:100%; } .chat-tools-popover { position:fixed; z-index:40; display:grid; box-sizing:border-box; width:296px; max-width:calc(100vw - 24px); gap:10px; overflow-x:hidden; padding:14px; border:1px solid var(--line); border-radius:10px; color:var(--text); background:var(--surface); box-shadow:0 16px 42px rgb(0 0 0 / .35); font-size:12px; }.chat-tools-popover > strong { font-size:13px; }.chat-tools-popover > p { margin:0; padding:7px 9px; border-radius:6px; color:var(--muted); background:var(--surface-raised); font-size:11px; }.chat-tools-popover > label { display:grid; gap:5px; min-width:0; color:var(--muted); }.chat-tools-popover input:not([type="checkbox"]) { width:100%; box-sizing:border-box; font:inherit; }.chat-tools-popover .chat-tool-popover-toggle { display:flex; align-items:center; justify-content:space-between; min-width:0; padding:8px 0; border-bottom:1px solid var(--line); color:var(--text); }.chat-tools-popover > div { display:flex; justify-content:flex-end; gap:8px; padding-top:2px; } -.chat-assistant { resize:both; min-width:min(320px,calc(100vw - 24px)); min-height:360px; max-width:calc(100vw - 24px); max-height:calc(100vh - 24px); }.chat-pane { display:grid; grid-row:2 / 5; grid-template-rows:auto minmax(0,1fr) auto; min-width:0; min-height:0; }.chat-pane--hidden { display:none; }.assistant-terminal { display:none; }.assistant-terminal--active { display:block; } +.chat-assistant { resize:both; min-width:min(320px,calc(100vw - 24px)); min-height:360px; max-width:calc(100vw - 24px); max-height:calc(100vh - 24px); }.chat-pane { position:relative; display:grid; grid-row:2 / 5; grid-template-rows:auto minmax(0,1fr) auto; min-width:0; min-height:0; }.chat-pane--hidden { display:none; }.chat-clear-confirmation { position:absolute; z-index:2; inset:0; display:grid; place-items:center; padding:18px; background:rgb(0 0 0 / .42); }.chat-clear-confirmation > div { display:grid; gap:10px; width:min(290px,100%); padding:16px; border:1px solid var(--line); border-radius:10px; background:var(--surface-raised); box-shadow:0 12px 34px rgb(0 0 0 / .32); }.chat-clear-confirmation p { margin:0; color:var(--muted); font-size:12px; line-height:1.55; }.chat-clear-confirmation footer { display:flex; justify-content:flex-end; gap:8px; }.assistant-terminal { display:none; }.assistant-terminal--active { display:block; } .chat-message--assistant > :first-child { margin-top:0; }.chat-message--assistant > :last-child { margin-bottom:0; }.chat-message--assistant h1,.chat-message--assistant h2,.chat-message--assistant h3 { margin:12px 0 7px; font-size:13px; }.chat-message--assistant p,.chat-message--assistant ul,.chat-message--assistant ol { margin:8px 0; }.chat-message--assistant ul,.chat-message--assistant ol { padding-left:18px; }.chat-message--assistant a { color:var(--accent); }.chat-message--assistant pre { overflow-x:hidden; margin:9px 0; padding:9px; border-radius:6px; white-space:pre-wrap; overflow-wrap:anywhere; background:var(--bg); font:10px/1.55 'DM Mono',monospace; }.chat-message--assistant code { font:10px 'DM Mono',monospace; }.chat-message--assistant :not(pre) > code { padding:2px 4px; border-radius:4px; background:var(--bg); }.chat-message--assistant table { display:table; width:100%; max-width:100%; table-layout:fixed; border-collapse:collapse; }.chat-message--assistant th,.chat-message--assistant td { padding:5px 7px; overflow-wrap:anywhere; border:1px solid var(--line); text-align:left; } .run-detail-trigger { border:0; padding:0; color:inherit; background:transparent; text-align:left; cursor:pointer; }.run-detail { position:fixed; z-index:15; top:0; right:0; width:min(560px,100vw); height:100vh; overflow:auto; padding:24px; border-left:1px solid var(--line); background:var(--surface); box-shadow:-16px 0 48px rgb(0 0 0 / .35); }.run-detail header { margin-bottom:18px; }.run-tabs { display:flex; gap:6px; border-bottom:1px solid var(--line); }.run-tabs button { border:0; border-bottom:2px solid transparent; padding:10px 12px; color:var(--muted); background:transparent; }.run-tabs button.active { border-color:var(--accent); color:var(--text); }.console-output section { padding:14px 0; border-bottom:1px solid var(--line); }.console-output small { display:block; color:var(--muted); margin:4px 0; }.line-numbered-output { max-width:100%; overflow:auto; padding:8px 0; border:1px solid var(--line); border-radius:8px; background:var(--bg); color:var(--text); font:11px/1.55 'DM Mono',monospace; }.line-numbered-output > div { display:grid; grid-template-columns:46px minmax(0,1fr); min-width:max-content; }.line-numbered-output span { padding:0 10px; border-right:1px solid var(--line); color:var(--muted); text-align:right; user-select:none; }.line-numbered-output code { min-width:0; padding:0 12px; color:inherit; white-space:pre; }.supervisor-translation-action { display:flex; justify-content:flex-end; margin-bottom:10px; }.supervisor-output { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; padding:0; }.supervisor-output section { display:grid; grid-template-rows:auto minmax(0,1fr); gap:8px; height:clamp(260px,calc(100vh - 390px),460px); min-height:0; padding:12px; border:1px solid var(--line); border-radius:8px; background:var(--surface-raised); }.supervisor-output .line-numbered-output { min-height:0; }.supervisor-output__head { display:flex; align-items:center; justify-content:space-between; gap:12px; }.supervisor-output p { margin:0; overflow:auto; color:var(--muted); } @media(max-width:760px){.supervisor-output { grid-template-columns:1fr; }.supervisor-output section { height:300px; }} .iteration-navigator { display:flex; align-items:center; justify-content:flex-end; gap:6px; min-height:48px; border-bottom:1px solid var(--line); }.iteration-navigator select { width:auto; min-width:76px; height:30px; padding:0 28px 0 10px; font:11px 'DM Mono',monospace; }.iteration-navigator .icon-button { display:grid; width:30px; height:30px; place-items:center; padding:0; }.iteration-navigator .icon-button:disabled { cursor:not-allowed; opacity:.4; } From e3aac3ad70f8c6063c23c4254ba25b593591187a Mon Sep 17 00:00:00 2001 From: forthfate Date: Sat, 26 Sep 2026 18:09:30 +0900 Subject: [PATCH 3/5] Restore Orbit assistant open state --- frontend/src/components/chat-assistant.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/chat-assistant.tsx b/frontend/src/components/chat-assistant.tsx index 996290e..8b372c1 100644 --- a/frontend/src/components/chat-assistant.tsx +++ b/frontend/src/components/chat-assistant.tsx @@ -78,6 +78,7 @@ type ChatAssistantCopy = { const positionKey = "orbit.chat.position"; const windowPositionKey = "orbit.chat.window.position"; const messagesKey = "orbit.chat.messages.v1"; +const openKey = "orbit.chat.open"; const maxStoredMessages = 100; const defaultToolSettings: ToolSettings = { workspace_root: "", @@ -140,6 +141,8 @@ const initialWindowPosition = (): Position | null => { return null; }; +const initialOpen = () => localStorage.getItem(openKey) === "true"; + const initialMessages = (): Message[] => { try { const saved = JSON.parse(localStorage.getItem(messagesKey) ?? "[]"); @@ -163,7 +166,7 @@ export function ChatAssistant() { resolveLocale(localStorage.getItem("orbit.locale")), "chatAssistant", ); - const [open, setOpen] = useState(false), + const [open, setOpen] = useState(initialOpen), [messages, setMessages] = useState(initialMessages), [draft, setDraft] = useState(""), [sending, setSending] = useState(false), @@ -221,6 +224,9 @@ export function ChatAssistant() { if (windowPosition) localStorage.setItem(windowPositionKey, JSON.stringify(windowPosition)); }, [windowPosition]); + useEffect(() => { + localStorage.setItem(openKey, String(open)); + }, [open]); useEffect(() => { try { if (messages.length) localStorage.setItem(messagesKey, JSON.stringify(messages)); From 4cc42e0a2d6d11b2018096b9c645796644906788 Mon Sep 17 00:00:00 2001 From: forthfate Date: Sat, 26 Sep 2026 18:10:26 +0900 Subject: [PATCH 4/5] Scope assistant state to browser tab --- frontend/src/components/chat-assistant.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/chat-assistant.tsx b/frontend/src/components/chat-assistant.tsx index 8b372c1..7c9dfdd 100644 --- a/frontend/src/components/chat-assistant.tsx +++ b/frontend/src/components/chat-assistant.tsx @@ -102,7 +102,7 @@ const normalizedToolSettings = ( }); const initialPosition = (): Position => { - const saved = localStorage.getItem(positionKey); + const saved = sessionStorage.getItem(positionKey); if (saved) { try { const position = JSON.parse(saved) as Position; @@ -128,7 +128,7 @@ const initialPosition = (): Position => { }; const initialWindowPosition = (): Position | null => { - const saved = localStorage.getItem(windowPositionKey); + const saved = sessionStorage.getItem(windowPositionKey); if (saved) { try { const position = JSON.parse(saved) as Position; @@ -141,11 +141,11 @@ const initialWindowPosition = (): Position | null => { return null; }; -const initialOpen = () => localStorage.getItem(openKey) === "true"; +const initialOpen = () => sessionStorage.getItem(openKey) === "true"; const initialMessages = (): Message[] => { try { - const saved = JSON.parse(localStorage.getItem(messagesKey) ?? "[]"); + const saved = JSON.parse(sessionStorage.getItem(messagesKey) ?? "[]"); if (!Array.isArray(saved)) return []; return saved .filter( @@ -201,7 +201,7 @@ export function ChatAssistant() { const hasUsedTool = useRef(false); const clearHistory = () => { setMessages([]); - localStorage.removeItem(messagesKey); + sessionStorage.removeItem(messagesKey); setClearConfirmationOpen(false); }; const appendMessage = (message: Message) => @@ -218,19 +218,19 @@ export function ChatAssistant() { return () => window.clearInterval(interval); }, [sending]); useEffect(() => { - localStorage.setItem(positionKey, JSON.stringify(position)); + sessionStorage.setItem(positionKey, JSON.stringify(position)); }, [position]); useEffect(() => { if (windowPosition) - localStorage.setItem(windowPositionKey, JSON.stringify(windowPosition)); + sessionStorage.setItem(windowPositionKey, JSON.stringify(windowPosition)); }, [windowPosition]); useEffect(() => { - localStorage.setItem(openKey, String(open)); + sessionStorage.setItem(openKey, String(open)); }, [open]); useEffect(() => { try { - if (messages.length) localStorage.setItem(messagesKey, JSON.stringify(messages)); - else localStorage.removeItem(messagesKey); + if (messages.length) sessionStorage.setItem(messagesKey, JSON.stringify(messages)); + else sessionStorage.removeItem(messagesKey); } catch { // A full or unavailable browser store must not prevent chatting. } From 265a825a40c4d98a9ecb435a63d86fc37e6435b4 Mon Sep 17 00:00:00 2001 From: forthfate Date: Sat, 26 Sep 2026 18:12:49 +0900 Subject: [PATCH 5/5] Scope transient UI state to browser tab --- frontend/src/app/app-shell.tsx | 4 ++-- frontend/src/features/assets/page.tsx | 12 ++++++------ frontend/src/services/build-selection.ts | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/app-shell.tsx b/frontend/src/app/app-shell.tsx index 6421141..4400bc5 100644 --- a/frontend/src/app/app-shell.tsx +++ b/frontend/src/app/app-shell.tsx @@ -40,7 +40,7 @@ export function AppShell({ }) { const t = locales[locale]; const [collapsed, setCollapsed] = useState( - () => localStorage.getItem(sidebarStorageKey) === "true", + () => sessionStorage.getItem(sidebarStorageKey) === "true", ); const pageDescriptions = localeMessages>( locale, @@ -106,7 +106,7 @@ export function AppShell({ ); const activeRunLabel = activeRunCount > 99 ? "99+" : String(activeRunCount); useEffect( - () => localStorage.setItem(sidebarStorageKey, String(collapsed)), + () => sessionStorage.setItem(sidebarStorageKey, String(collapsed)), [collapsed], ); useEffect(() => { diff --git a/frontend/src/features/assets/page.tsx b/frontend/src/features/assets/page.tsx index 29d2c21..e193f0b 100644 --- a/frontend/src/features/assets/page.tsx +++ b/frontend/src/features/assets/page.tsx @@ -969,7 +969,7 @@ function PromptTemplateEditor({ ? template.versions : [{ version: template.version, content: template.content }]; const [draft, setDraft] = useState(() => { - const saved = localStorage.getItem(draftKey); + const saved = sessionStorage.getItem(draftKey); return saved ? (JSON.parse(saved) as PromptTemplate) : { ...template }; }), [selectedVersion, setSelectedVersion] = useState(draft.version), @@ -982,8 +982,8 @@ function PromptTemplateEditor({ draft.content !== selectedContent || draft.id !== template.id; useEffect(() => { - if (dirty) localStorage.setItem(draftKey, JSON.stringify(draft)); - else localStorage.removeItem(draftKey); + if (dirty) sessionStorage.setItem(draftKey, JSON.stringify(draft)); + else sessionStorage.removeItem(draftKey); }, [draft, dirty, draftKey]); const close = () => { if (dirty) setPendingAction({ type: "close" }); @@ -994,7 +994,7 @@ function PromptTemplateEditor({ if (selected) { setSelectedVersion(version); setDraft({ ...template, content: selected.content, version }); - localStorage.removeItem(draftKey); + sessionStorage.removeItem(draftKey); } }; const selectVersion = (version: number) => { @@ -1003,7 +1003,7 @@ function PromptTemplateEditor({ }; const discard = () => { if (!pendingAction) return; - localStorage.removeItem(draftKey); + sessionStorage.removeItem(draftKey); if (pendingAction.type === "close") onClose(); else applyVersion(pendingAction.version); setPendingAction(null); @@ -1017,7 +1017,7 @@ function PromptTemplateEditor({ { id: draft.id, name: draft.name, content: draft.content }, ) .then(async () => { - localStorage.removeItem(draftKey); + sessionStorage.removeItem(draftKey); await onSaved(); onClose(); }) diff --git a/frontend/src/services/build-selection.ts b/frontend/src/services/build-selection.ts index b720bd4..7393bd8 100644 --- a/frontend/src/services/build-selection.ts +++ b/frontend/src/services/build-selection.ts @@ -10,7 +10,7 @@ export function preferredBuildId( fallback: string, ): string { try { - const saved = localStorage.getItem(storageKey(scope)); + const saved = sessionStorage.getItem(storageKey(scope)); return saved && builds.some((build) => build.id === saved) ? saved : fallback; } catch { return fallback; @@ -19,7 +19,7 @@ export function preferredBuildId( export function savePreferredBuildId(scope: "improvements", buildId: string) { try { - if (buildId) localStorage.setItem(storageKey(scope), buildId); + if (buildId) sessionStorage.setItem(storageKey(scope), buildId); } catch { // Storage can be unavailable in private or restricted browser contexts. } @@ -27,7 +27,7 @@ export function savePreferredBuildId(scope: "improvements", buildId: string) { export function preferredRangeHours(scope: "improvements", fallback: number): number { try { - const saved = Number(localStorage.getItem(rangeStorageKey(scope))); + const saved = Number(sessionStorage.getItem(rangeStorageKey(scope))); return validRangeHours.has(saved) ? saved : fallback; } catch { return fallback; @@ -36,7 +36,7 @@ export function preferredRangeHours(scope: "improvements", fallback: number): nu export function savePreferredRangeHours(scope: "improvements", hours: number) { try { - if (validRangeHours.has(hours)) localStorage.setItem(rangeStorageKey(scope), String(hours)); + if (validRangeHours.has(hours)) sessionStorage.setItem(rangeStorageKey(scope), String(hours)); } catch { // Storage can be unavailable in private or restricted browser contexts. }