diff --git a/source/app/App.tsx b/source/app/App.tsx index b89d070bd..639e84ae4 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -42,6 +42,7 @@ import {UIStateProvider} from '@/hooks/useUIState'; import {useUserMessageQueue} from '@/hooks/useUserMessageQueue'; import {useVSCodeServer} from '@/hooks/useVSCodeServer'; import {generateKey} from '@/session/key-generator'; +import {sessionManager} from '@/session/session-manager'; import type {ImageAttachment} from '@/types/core'; import type {ThemePreset} from '@/types/ui'; import {createPinoLogger} from '@/utils/logging/pino-logger'; @@ -501,12 +502,14 @@ export default function App({ submitMessage: appHandlers.handleMessageSubmit, cancel: appHandlers.handleCancel, resetSession: appHandlers.clearMessages, + applySession: appHandlers.applySession, }); webRuntimeStateRef.current = { isGenerating: chatHandler.isGenerating, submitMessage: appHandlers.handleMessageSubmit, cancel: appHandlers.handleCancel, resetSession: appHandlers.clearMessages, + applySession: appHandlers.applySession, }; React.useEffect(() => { @@ -537,6 +540,59 @@ export default function App({ return webRuntimeStateRef.current.resetSession(); }, + listSessions: async () => { + await sessionManager.initialize(); + const sessions = await sessionManager.listSessions({ + workingDirectory: process.cwd(), + }); + + return [...sessions] + .sort( + (a, b) => + new Date(b.lastAccessedAt).getTime() - + new Date(a.lastAccessedAt).getTime(), + ) + .map(session => ({ + id: session.id, + title: session.title, + lastAccessedAt: session.lastAccessedAt, + messageCount: session.messageCount, + })); + }, + loadSession: async sessionId => { + if (webRuntimeStateRef.current.isGenerating) { + throw new Error( + 'Cannot switch sessions while Nanocoder is processing a turn.', + ); + } + + await sessionManager.initialize(); + const session = await sessionManager.loadSession(sessionId); + if (!session) { + return null; + } + + webRuntimeStateRef.current.applySession(session); + + return { + session: { + id: session.id, + title: session.title, + lastAccessedAt: session.lastAccessedAt, + messageCount: session.messageCount, + }, + messages: session.messages + .filter( + message => + (message.role === 'user' || message.role === 'assistant') && + message.content.trim().length > 0, + ) + .map(message => ({ + role: message.role as 'user' | 'assistant', + content: message.content, + })), + }; + }, }); }, [ webRuntimeBridge, diff --git a/source/web/page.spec.ts b/source/web/page.spec.ts index 13332aa9e..448e8030e 100644 --- a/source/web/page.spec.ts +++ b/source/web/page.spec.ts @@ -27,6 +27,17 @@ test('web mode page styles the sidebar and message scrollbars instead of using t t.true(page.includes('scrollbar-width: thin;')); }); +test('web mode page snaps the message list to the newest content instead of animating every scroll', t => { + const page = renderWebModePage(); + + // scrollTop is reassigned on every appendMessage()/appendAssistantDelta() + // call, i.e. once per streamed token. CSS scroll-behavior: smooth turns + // each of those into a queued animation; browsers throttle rAF work in a + // backgrounded tab, so the queue drains all at once, as a visible jump, + // the moment the tab regains focus. + t.false(page.includes('scroll-behavior: smooth')); +}); + test('web mode page tells the backend to reset the session when starting a new chat', t => { const page = renderWebModePage(); @@ -151,7 +162,160 @@ test('web mode shows live tool running and completed status', t => { t.true(page.includes("'Tool finished: ' + message.name")); t.true( page.includes( - "'Provider and model stay in the terminal runtime. During a browser turn, approvals and questions are answered here.'", + 'Provider and model stay in the terminal runtime; during a browser turn, approvals and questions are answered here.', + ), + ); +}); + +test('web mode page ships a light theme that cannot affect the dark default', t => { + const page = renderWebModePage(); + + t.true(page.includes(':root[data-theme="light"] {')); + t.true(page.includes(':root[data-theme="light"] body {')); + t.true(page.includes(':root[data-theme="light"] .message.user {')); + // Every light rule is scoped by the attribute selector, so it can only ever + // apply once is set; it never edits an existing + // dark rule. + const lightRuleCount = (page.match(/:root\[data-theme="light"\]/gu) ?? []).length; + t.true(lightRuleCount > 20); +}); + +test('web mode page toggles and persists the theme', t => { + const page = renderWebModePage(); + + t.true(page.includes("id=\"themeToggleButton\"")); + t.true(page.includes('function applyTheme(theme)')); + t.true(page.includes("document.documentElement.dataset.theme = theme")); + t.true(page.includes("window.localStorage.setItem(themeStorageKey, theme)")); + t.true( + page.includes( + "window.matchMedia('(prefers-color-scheme: light)').matches", + ), + ); + t.true( + page.includes( + "applyTheme(document.documentElement.dataset.theme === 'light' ? 'dark' : 'light')", + ), + ); +}); + +test('web mode page collapses and persists the sidebar', t => { + const page = renderWebModePage(); + + t.true(page.includes('id="sidebarToggleButton"')); + t.true(page.includes('.app-shell.sidebar-collapsed {')); + t.true(page.includes('.app-shell.sidebar-collapsed .sidebar {')); + t.true(page.includes('function applySidebarCollapsed(isCollapsed)')); + t.true(page.includes("appShell.classList.toggle('sidebar-collapsed', isCollapsed)")); + t.true( + page.includes( + "applySidebarCollapsed(!appShell.classList.contains('sidebar-collapsed'))", + ), + ); + t.true(page.includes('window.localStorage.setItem(sidebarStorageKey')); +}); + +test('web mode page reduces metadata label weight so it does not compete with primary text', t => { + const page = renderWebModePage(); + + t.true( + page.includes('.meta {\n\t\t\tcolor: rgba(245, 242, 235, 0.5);\n\t\t\tfont-size: 11px;'), + ); + t.false(page.includes('font-size: 12px;\n\t\t}\n\t\t.message.user .meta')); +}); + +test('web mode markdown renderer supports italics, strikethrough, and links', t => { + const page = renderWebModePage(); + + t.true(page.includes('function appendInlineMarkdown(element, text)')); + t.true(page.includes("const isBold = remainingText.startsWith('**')")); + t.true(page.includes("const isStrike = !isBold && remainingText.startsWith('~~')")); + t.true( + page.includes( + "const isItalic = !isBold && !isStrike && !isCode && remainingText.startsWith('*')", + ), + ); + t.true(page.includes("tagName = isBold ? 'strong' : isStrike ? 's' : isCode ? 'code' : 'em'")); + // Link handling: parsed with its own regex ahead of the marker scan, and + // recurses on the link text so `[**bold** link](url)` still bolds inside it. + t.true(page.includes("anchor.rel = 'noopener noreferrer'")); + t.true(page.includes('appendInlineMarkdown(anchor, linkMatch[1])')); +}); + +test('web mode code blocks get language-aware syntax highlighting', t => { + const page = renderWebModePage(); + + t.true(page.includes('function highlightCode(codeElement, rawText, language)')); + t.true(page.includes("codeElement.className = language ? 'language-' + language : ''")); + t.true(page.includes("span.className = 'tok-' + tokenType")); + t.true(page.includes('rawText.matchAll(CODE_TOKEN_PATTERN)')); + // Language tag is read from the opening fence line, e.g. ```js. + t.true(page.includes('codeLang = line.trim().slice(codeFence.length).trim()')); +}); + +test('web mode page requests real session history instead of showing hardcoded threads', t => { + const page = renderWebModePage(); + + // The three hardcoded thread buttons are gone; the sidebar starts empty + // and is populated once the backend replies. + t.false(page.includes('data-thread-label="Nanocoder web mode"')); + t.false(page.includes('Runtime bridge next')); + t.false(page.includes('Tool approvals')); + t.true(page.includes('id="threadListEmpty"')); + t.true(page.includes("sendClientEvent({type: 'list_sessions'")); + t.true(page.includes('function renderThreadList(sessions)')); + t.true(page.includes('function applyLoadedSession(sessionSummary, messages)')); +}); + +test('web mode page loads a session on click and guards against switching mid-turn', t => { + const page = renderWebModePage(); + + t.true(page.includes("threadList.addEventListener('click'")); + t.true(page.includes("event.target.closest('.thread-item')")); + t.true( + page.includes( + "sendClientEvent({\n\t\t\t\t\ttype: 'load_session',\n\t\t\t\t\tid: 'browser-load-' + Date.now(),\n\t\t\t\t\tsessionId: target.dataset.sessionId,\n\t\t\t\t});", + ), + ); + t.true( + page.includes( + "'Finish or cancel the current turn before switching sessions.'", + ), + ); +}); + +test('web mode page handles the sessions and session_loaded server events', t => { + const page = renderWebModePage(); + + t.true(page.includes("if (message.type === 'sessions') {")); + t.true(page.includes('renderThreadList(message.sessions)')); + t.true(page.includes("if (message.type === 'session_loaded') {")); + t.true(page.includes('applyLoadedSession(message.session, message.messages)')); +}); + +test('web mode history button reveals and refreshes the real session list', t => { + const page = renderWebModePage(); + + const historyHandlerIndex = page.indexOf("historyButton.addEventListener('click'"); + const listSessionsIndex = page.indexOf( + "type: 'list_sessions'", + historyHandlerIndex, + ); + t.true(historyHandlerIndex >= 0 && listSessionsIndex > historyHandlerIndex); + t.true(page.includes('applySidebarCollapsed(false)')); +}); + +test('web mode settings button shows real current state instead of a canned notice', t => { + const page = renderWebModePage(); + + t.true( + page.includes( + "document.documentElement.dataset.theme === 'light' ? 'Light' : 'Dark'", + ), + ); + t.true( + page.includes( + "appShell.classList.contains('sidebar-collapsed')\n\t\t\t\t\t? 'collapsed'\n\t\t\t\t\t: 'expanded'", ), ); }); diff --git a/source/web/page.ts b/source/web/page.ts index b8cf0e772..37bd15252 100644 --- a/source/web/page.ts +++ b/source/web/page.ts @@ -52,6 +52,15 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { background: linear-gradient(90deg, #090b0d 0, #0d1114 280px, #11161a 280px, #151a1f 100%); } + .app-shell.sidebar-collapsed { + grid-template-columns: 0 minmax(0, 1fr); + } + .app-shell.sidebar-collapsed .sidebar { + padding: 0; + border-right: 0; + opacity: 0; + pointer-events: none; + } .sidebar { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; @@ -61,6 +70,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { border-right: 1px solid rgba(245, 242, 235, 0.08); background: rgba(9, 12, 14, 0.96); overflow: hidden; + transition: opacity 150ms ease; } .brand-row { display: flex; @@ -189,6 +199,12 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { color: #9b92a4; font-size: 13px; } + .thread-list-empty { + margin: 0; + padding: 6px 10px; + color: #8e969d; + font-size: 13px; + } .workspace { position: relative; display: grid; @@ -279,7 +295,6 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { overflow-y: auto; pointer-events: none; padding: 28px clamp(18px, 10vw, 160px) 160px; - scroll-behavior: smooth; scrollbar-width: thin; scrollbar-color: rgba(245, 242, 235, 0.18) transparent; } @@ -379,6 +394,19 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { background: transparent; padding: 0; } + .tok-keyword { + color: var(--tn-primary); + } + .tok-string { + color: var(--tn-success); + } + .tok-number { + color: var(--tn-warning); + } + .tok-comment { + color: var(--tn-secondary); + font-style: italic; + } .message.system { align-self: center; width: min(760px, 100%); @@ -561,8 +589,8 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { color: #ffc4c4; } .meta { - color: rgba(245, 242, 235, 0.58); - font-size: 12px; + color: rgba(245, 242, 235, 0.5); + font-size: 11px; } .message.user .meta { color: rgba(17, 20, 24, 0.62); @@ -773,6 +801,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { } .search-box input::placeholder, .thread-item, + .thread-list-empty, .sidebar-footer, .session-note, p, @@ -847,7 +876,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { border-color: rgba(247, 118, 142, 0.45); } .meta { - color: rgba(192, 202, 245, 0.62); + color: rgba(192, 202, 245, 0.52); } .message.user .meta { color: rgba(26, 27, 38, 0.64); @@ -867,6 +896,253 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { .send-button:not(:disabled):focus-visible { background: #9de1ff; } + /* + * Light theme. Scoped with the [data-theme="light"] attribute selector + * (set on by the theme toggle below) rather than editing the dark + * rules above, so light mode is purely additive: the attribute selector + * always outranks the plain class selectors it targets, regardless of + * source order, and the dark theme's appearance is provably unchanged. + */ + :root[data-theme="light"] { + color-scheme: light; + } + :root[data-theme="light"] body { + background: #f6f6fb; + color: #1c1d2b; + } + :root[data-theme="light"] .app-shell { + background: + linear-gradient(90deg, #ffffff 0, #ffffff 280px, #f6f6fb 280px, #f0f0f7 100%); + } + :root[data-theme="light"] .sidebar { + border-right-color: rgba(28, 29, 43, 0.1); + background: #ffffff; + } + :root[data-theme="light"] .brand, + :root[data-theme="light"] .thread-item.active, + :root[data-theme="light"] .thread-item:hover, + :root[data-theme="light"] .prompt-button:hover, + :root[data-theme="light"] .empty-state, + :root[data-theme="light"] .message, + :root[data-theme="light"] textarea, + :root[data-theme="light"] .model-pill { + color: #1c1d2b; + } + :root[data-theme="light"] .icon-button { + background: rgba(28, 29, 43, 0.06); + color: #1c1d2b; + } + :root[data-theme="light"] .icon-button:hover, + :root[data-theme="light"] .icon-button:focus-visible { + background: rgba(28, 29, 43, 0.1); + } + :root[data-theme="light"] .new-chat { + border-color: rgba(124, 92, 214, 0.4); + background: rgba(124, 92, 214, 0.1); + color: #5c3fb8; + } + :root[data-theme="light"] .new-chat:hover { + background: rgba(124, 92, 214, 0.18); + } + :root[data-theme="light"] .search-box { + border-color: rgba(28, 29, 43, 0.1); + color: #5b6178; + } + :root[data-theme="light"] .search-box:focus-within { + border-color: rgba(46, 134, 193, 0.4); + background: rgba(46, 134, 193, 0.06); + } + :root[data-theme="light"] .search-box input { + color: #1c1d2b; + } + :root[data-theme="light"] .search-box input::placeholder, + :root[data-theme="light"] .thread-item, + :root[data-theme="light"] .thread-list-empty, + :root[data-theme="light"] .sidebar-footer, + :root[data-theme="light"] .session-note, + :root[data-theme="light"] p, + :root[data-theme="light"] .empty-state span, + :root[data-theme="light"] .note { + color: #5b6178; + } + :root[data-theme="light"] .thread-list::-webkit-scrollbar-thumb, + :root[data-theme="light"] .messages::-webkit-scrollbar-thumb { + background: rgba(28, 29, 43, 0.16); + } + :root[data-theme="light"] .thread-list::-webkit-scrollbar-thumb:hover, + :root[data-theme="light"] .messages::-webkit-scrollbar-thumb:hover { + background: rgba(28, 29, 43, 0.28); + } + :root[data-theme="light"] .thread-list, + :root[data-theme="light"] .messages { + scrollbar-color: rgba(28, 29, 43, 0.16) transparent; + } + :root[data-theme="light"] .thread-item.active, + :root[data-theme="light"] .thread-item:hover, + :root[data-theme="light"] .thread-item:focus-visible, + :root[data-theme="light"] .mode-pill, + :root[data-theme="light"] .prompt-button:hover { + background: rgba(46, 134, 193, 0.1); + } + :root[data-theme="light"] .workspace { + background: + radial-gradient(circle at 50% 16%, rgba(124, 92, 214, 0.08), transparent 28rem), + linear-gradient(180deg, #f6f6fb 0%, #f2f2f8 52%, #ececf4 100%); + } + :root[data-theme="light"] .status { + border-color: rgba(28, 29, 43, 0.12); + background: rgba(255, 255, 255, 0.78); + color: #1c1d2b; + } + :root[data-theme="light"] .status::before { + background: #b3791f; + box-shadow: 0 0 0 5px rgba(179, 121, 31, 0.14); + } + :root[data-theme="light"] .status.connected { + color: #1a9e63; + } + :root[data-theme="light"] .status.connected::before { + background: #1a9e63; + box-shadow: 0 0 0 5px rgba(26, 158, 99, 0.14); + } + :root[data-theme="light"] .status.disconnected, + :root[data-theme="light"] .status.failed, + :root[data-theme="light"] .message.error { + color: #b8253f; + } + :root[data-theme="light"] .status.disconnected::before, + :root[data-theme="light"] .status.failed::before { + background: #d1435b; + box-shadow: 0 0 0 5px rgba(209, 67, 91, 0.14); + } + :root[data-theme="light"] .markdown h1, + :root[data-theme="light"] .markdown h2, + :root[data-theme="light"] .markdown h3 { + color: #1c1d2b; + } + :root[data-theme="light"] .markdown code { + background: rgba(46, 134, 193, 0.1); + } + :root[data-theme="light"] .markdown pre, + :root[data-theme="light"] .interaction-card pre { + border-color: rgba(28, 29, 43, 0.12); + background: #eef0f7; + } + :root[data-theme="light"] .tok-keyword { + color: #5c3fb8; + } + :root[data-theme="light"] .tok-string { + color: #1a9e63; + } + :root[data-theme="light"] .tok-number { + color: #b3791f; + } + :root[data-theme="light"] .tok-comment { + color: #5b6178; + } + :root[data-theme="light"] .message { + border-color: rgba(28, 29, 43, 0.1); + background: rgba(28, 29, 43, 0.035); + box-shadow: 0 12px 40px rgba(28, 29, 43, 0.06); + } + :root[data-theme="light"] .message.user { + background: #1c1d2b; + color: #fbfbfd; + } + :root[data-theme="light"] .message.user .meta { + color: rgba(251, 251, 253, 0.62); + } + :root[data-theme="light"] .message.assistant { + background: transparent; + } + :root[data-theme="light"] .message.system, + :root[data-theme="light"] .mode-pill { + background: rgba(28, 29, 43, 0.045); + } + :root[data-theme="light"] .message.system { + color: #1c1d2b; + } + :root[data-theme="light"] .message.interaction { + border-color: rgba(46, 134, 193, 0.3); + background: rgba(46, 134, 193, 0.06); + } + :root[data-theme="light"] .interaction-actions button, + :root[data-theme="light"] .question-options button { + border-color: rgba(46, 134, 193, 0.32); + background: rgba(46, 134, 193, 0.1); + color: #1c1d2b; + } + :root[data-theme="light"] .interaction-actions button[data-approved="false"] { + border-color: rgba(209, 67, 91, 0.35); + background: rgba(209, 67, 91, 0.1); + } + :root[data-theme="light"] .question-freeform input { + border-color: rgba(28, 29, 43, 0.14); + background: #ffffff; + color: #1c1d2b; + } + :root[data-theme="light"] .mode-pill { + border-color: rgba(28, 29, 43, 0.1); + } + :root[data-theme="light"] .mode-pill:hover, + :root[data-theme="light"] .mode-pill:focus-visible { + background: rgba(46, 134, 193, 0.12); + border-color: rgba(46, 134, 193, 0.36); + color: #1c1d2b; + } + :root[data-theme="light"] .prompt-button { + border-color: rgba(28, 29, 43, 0.1); + background: rgba(28, 29, 43, 0.025); + color: #33364a; + } + :root[data-theme="light"] .prompt-button::after { + color: rgba(46, 134, 193, 0.6); + } + :root[data-theme="light"] .prompt-button:hover, + :root[data-theme="light"] .prompt-button:focus-visible { + border-color: rgba(46, 134, 193, 0.32); + color: #1c1d2b; + } + :root[data-theme="light"] .prompt-button:hover::after, + :root[data-theme="light"] .prompt-button:focus-visible::after { + color: #2e86c1; + } + :root[data-theme="light"] .message.error { + border-color: rgba(209, 67, 91, 0.4); + } + :root[data-theme="light"] .meta { + color: rgba(28, 29, 43, 0.48); + } + :root[data-theme="light"] .composer { + border-color: rgba(28, 29, 43, 0.12); + background: #ffffff; + box-shadow: 0 24px 80px rgba(28, 29, 43, 0.1); + } + :root[data-theme="light"] .composer.is-attention { + border-color: rgba(46, 134, 193, 0.5); + box-shadow: + 0 0 0 3px rgba(46, 134, 193, 0.14), + 0 24px 80px rgba(28, 29, 43, 0.1); + } + :root[data-theme="light"] textarea::placeholder { + color: #7d8296; + } + :root[data-theme="light"] .send-button { + background: #2e86c1; + color: #ffffff; + } + :root[data-theme="light"] .send-button:not(:disabled):hover, + :root[data-theme="light"] .send-button:not(:disabled):focus-visible { + background: #3f99d6; + } + :root[data-theme="light"] .send-button.is-cancel { + background: #d1435b; + color: #ffffff; + } + :root[data-theme="light"] .send-button.is-cancel:not(:disabled):hover, + :root[data-theme="light"] .send-button.is-cancel:not(:disabled):focus-visible { + background: #dd5b71; + } @@ -877,6 +1153,7 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { Nanocoder logo Nanocoder + @@ -884,10 +1161,8 @@ export function renderWebModePage(nonce: string = createPageNonce()): string { -
- - - +
+

Loading sessions...