Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions source/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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,
Expand Down
166 changes: 165 additions & 1 deletion source/web/page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 <html data-theme="light"> 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'",
),
);
});
Loading