From 77c388337d9557c909a241a3e3c4042279f4f560 Mon Sep 17 00:00:00 2001 From: QinRui Date: Thu, 20 Aug 2026 17:37:44 +0800 Subject: [PATCH 1/6] feat: improve mobile conversation experience --- src/main/mobile/lan-mobile-bridge.ts | 293 +++++++++++++++++++++++++++ src/main/mobile/lan-mobile-pages.ts | 98 +++++++-- test/lan-mobile-bridge.test.ts | 291 +++++++++++++++++++++++++- test/lan-mobile-pages.test.ts | 74 ++++++- 4 files changed, 731 insertions(+), 25 deletions(-) diff --git a/src/main/mobile/lan-mobile-bridge.ts b/src/main/mobile/lan-mobile-bridge.ts index 95ee9206..0d462271 100644 --- a/src/main/mobile/lan-mobile-bridge.ts +++ b/src/main/mobile/lan-mobile-bridge.ts @@ -13,11 +13,16 @@ import { const MAX_BODY_BYTES = 64 * 1024 const PAIRING_TTL_MS = 5 * 60 * 1000 +const MUX_RECONNECT_MS = 500 const RPC_ALLOWLIST = new Set([ 'workspace.list', + 'agentPreset.list', + 'agentPreset.select', 'session.list', 'session.history', + 'session.models', + 'session.selectModel', 'session.create', 'session.prompt', 'session.cancel' @@ -54,6 +59,33 @@ interface PendingPairing { decision?: boolean } +interface MobileQuestionOption { + label: string + description?: string +} + +interface MobileQuestion { + id: string + question: string + detail?: string + header?: string + options?: MobileQuestionOption[] + multiSelect?: boolean + intent?: string +} + +interface PendingMobileQuestion { + rpcId: string + sessionId: string + questions: MobileQuestion[] +} + +interface MobileQuestionAnswer { + id: string + selected: string[] + custom?: string +} + export class LanMobileBridge { private server?: ReturnType private port?: number @@ -62,7 +94,10 @@ export class LanMobileBridge { private readonly sessions = new Map() private readonly suspendedSessions = new Map() private readonly pendingPairings = new Map() + private readonly pendingQuestions = new Map() private readonly now: () => number + private muxAbort?: AbortController + private muxTask?: Promise constructor(private readonly options: LanMobileBridgeOptions) { this.now = options.now ?? Date.now @@ -73,6 +108,7 @@ export class LanMobileBridge { if (!this.pairingToken || !this.pairingExpiresAt || this.pairingExpiresAt < this.now()) { this.rotatePairingToken() } + this.startMuxMonitor() return this.snapshot() } this.rotatePairingToken() @@ -87,6 +123,7 @@ export class LanMobileBridge { this.server?.listen(this.options.port ?? 0, '0.0.0.0', resolve) }) this.port = (this.server.address() as AddressInfo).port + this.startMuxMonitor() return this.snapshot() } @@ -99,6 +136,12 @@ export class LanMobileBridge { this.sessions.clear() this.suspendedSessions.clear() this.pendingPairings.clear() + this.pendingQuestions.clear() + this.muxAbort?.abort() + const muxTask = this.muxTask + this.muxAbort = undefined + this.muxTask = undefined + if (muxTask) await muxTask.catch(() => undefined) if (!server) return await new Promise((resolve) => server.close(() => resolve())) } @@ -298,6 +341,37 @@ export class LanMobileBridge { if (request.method === 'POST' && url.pathname === '/api/rpc') { this.verifySameOrigin(request) const input = JSON.parse(await readBody(request)) as { method?: unknown; payload?: unknown } + if (input.method === 'interaction.pending') { + const sessionId = requiredStringField(input.payload, 'sessionId') + const pending = [...this.pendingQuestions.values()].find( + (item) => item.sessionId === sessionId + ) + return this.json(response, 200, { ok: true, value: pending ?? null }) + } + if (input.method === 'interaction.answer') { + const answer = parseQuestionResponse(input.payload) + const pending = this.assertPendingQuestion(answer.rpcId, answer.sessionId) + validateQuestionAnswers(pending, answer.answers) + const result = await this.respondToQuestion(answer.rpcId, { + ok: true, + value: { sessionId: answer.sessionId, answer: { answers: answer.answers } } + }) + return this.json(response, result.ok ? 200 : 400, result) + } + if (input.method === 'interaction.cancel') { + const rpcId = requiredStringField(input.payload, 'rpcId') + const sessionId = requiredStringField(input.payload, 'sessionId') + this.assertPendingQuestion(rpcId, sessionId) + const result = await this.respondToQuestion(rpcId, { + ok: false, + error: { + code: 'cancelled', + message: 'the user closed this question request', + details: {} + } + }) + return this.json(response, result.ok ? 200 : 400, result) + } if (typeof input.method !== 'string' || !RPC_ALLOWLIST.has(input.method)) { return this.json(response, 403, { ok: false, error: 'RPC method is not available on mobile.' }) } @@ -395,6 +469,125 @@ export class LanMobileBridge { return { ok: true, value: envelope.result.value } } + private startMuxMonitor(): void { + if (this.muxTask) return + const abort = new AbortController() + this.muxAbort = abort + this.muxTask = this.monitorMux(abort.signal).finally(() => { + if (this.muxAbort === abort) { + this.muxAbort = undefined + this.muxTask = undefined + } + }) + } + + private async monitorMux(signal: AbortSignal): Promise { + let lastBase: string | undefined + while (!signal.aborted) { + const base = this.options.harnessUrl() + if (!base) { + this.pendingQuestions.clear() + await waitFor(MUX_RECONNECT_MS, signal) + continue + } + if (base !== lastBase) { + this.pendingQuestions.clear() + lastBase = base + } + try { + await this.consumeMux(base, signal) + } catch { + if (signal.aborted) return + this.pendingQuestions.clear() + await waitFor(MUX_RECONNECT_MS, signal) + } + } + } + + private async consumeMux(base: string, signal: AbortSignal): Promise { + const response = await fetch(new URL('/api/events.mux', base), { + headers: { accept: 'text/event-stream' }, + signal + }) + if (!response.ok || !response.body) { + throw new Error(`Harness event stream returned HTTP ${response.status}.`) + } + this.pendingQuestions.clear() + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (!signal.aborted) { + const { done, value } = await reader.read() + if (done) return + buffer += decoder.decode(value, { stream: true }) + let boundary = buffer.indexOf('\n\n') + while (boundary >= 0) { + const frame = buffer.slice(0, boundary) + buffer = buffer.slice(boundary + 2) + this.consumeMuxFrame(frame) + boundary = buffer.indexOf('\n\n') + } + } + } + + private consumeMuxFrame(frame: string): void { + const data = frame + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n') + if (!data) return + let envelope: unknown + try { + envelope = JSON.parse(data) + } catch { + return + } + if (!isRecord(envelope) || envelope.type !== 'server-request') return + const rpcId = typeof envelope.rpcId === 'string' ? envelope.rpcId : undefined + const payload = isRecord(envelope.payload) ? envelope.payload : undefined + if (!rpcId || !payload || typeof payload.type !== 'string') return + if (payload.type === 'question/requested') { + const pending = parsePendingQuestion(rpcId, payload) + if (pending) this.pendingQuestions.set(rpcId, pending) + return + } + if (payload.type === 'question/resolved' && typeof payload.questionRpcId === 'string') { + this.pendingQuestions.delete(payload.questionRpcId) + } + } + + private assertPendingQuestion(rpcId: string, sessionId: string): PendingMobileQuestion { + const pending = this.pendingQuestions.get(rpcId) + if (!pending || pending.sessionId !== sessionId) { + throw new Error('This question request is no longer pending.') + } + return pending + } + + private async respondToQuestion( + rpcId: string, + result: Record + ): Promise<{ ok: boolean; value?: unknown; error?: string }> { + const base = this.options.harnessUrl() + if (!base) return { ok: false, error: 'Harness is not ready.' } + const response = await fetch(new URL('/api/respond', base), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-response', rpcId, result }), + signal: AbortSignal.timeout(30_000) + }) + if (!response.ok) return { ok: false, error: `Harness transport returned HTTP ${response.status}.` } + const receipt = (await response.json()) as { accepted?: unknown; reason?: unknown } + if (receipt.accepted !== true) { + return { + ok: false, + error: typeof receipt.reason === 'string' ? receipt.reason : 'Harness rejected the response.' + } + } + return { ok: true, value: receipt } + } + private html(response: ServerResponse, body: string): void { response.statusCode = 200 response.setHeader('content-type', 'text/html; charset=utf-8') @@ -454,3 +647,103 @@ async function readBody(request: IncomingMessage): Promise { } return Buffer.concat(chunks).toString('utf8') } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function requiredStringField(value: unknown, field: string): string { + if (!isRecord(value) || typeof value[field] !== 'string' || !value[field]) { + throw new Error(`Invalid ${field}.`) + } + return value[field] +} + +function parsePendingQuestion( + rpcId: string, + payload: Record +): PendingMobileQuestion | undefined { + if (typeof payload.sessionId !== 'string' || !Array.isArray(payload.questions)) return undefined + const questions: MobileQuestion[] = [] + for (const item of payload.questions.slice(0, 20)) { + if (!isRecord(item) || typeof item.id !== 'string' || typeof item.question !== 'string') continue + const question: MobileQuestion = { id: item.id, question: item.question } + if (typeof item.detail === 'string') question.detail = item.detail + if (typeof item.header === 'string') question.header = item.header + if (typeof item.multiSelect === 'boolean') question.multiSelect = item.multiSelect + if (typeof item.intent === 'string') question.intent = item.intent + if (Array.isArray(item.options)) { + question.options = item.options.slice(0, 50).flatMap((option) => { + if (!isRecord(option) || typeof option.label !== 'string') return [] + return [{ + label: option.label, + ...(typeof option.description === 'string' ? { description: option.description } : {}) + }] + }) + } + questions.push(question) + } + if (!questions.length) return undefined + return { rpcId, sessionId: payload.sessionId, questions } +} + +function parseQuestionResponse(value: unknown): { + rpcId: string + sessionId: string + answers: MobileQuestionAnswer[] +} { + const rpcId = requiredStringField(value, 'rpcId') + const sessionId = requiredStringField(value, 'sessionId') + if (!isRecord(value) || !Array.isArray(value.answers) || value.answers.length > 20) { + throw new Error('Invalid question answers.') + } + const answers = value.answers.map((item): MobileQuestionAnswer => { + if (!isRecord(item) || typeof item.id !== 'string' || !Array.isArray(item.selected)) { + throw new Error('Invalid question answer.') + } + const selected = item.selected.map((label) => { + if (typeof label !== 'string') throw new Error('Invalid selected option.') + return label + }) + if (selected.length > 50) throw new Error('Too many selected options.') + return { + id: item.id, + selected, + ...(typeof item.custom === 'string' && item.custom.trim() ? { custom: item.custom } : {}) + } + }) + return { rpcId, sessionId, answers } +} + +function validateQuestionAnswers( + pending: PendingMobileQuestion, + answers: MobileQuestionAnswer[] +): void { + if (answers.length !== pending.questions.length) throw new Error('Every question needs an answer or skip.') + const answerById = new Map(answers.map((answer) => [answer.id, answer])) + if (answerById.size !== answers.length) throw new Error('Duplicate question answer.') + for (const question of pending.questions) { + const answer = answerById.get(question.id) + if (!answer) throw new Error('Every question needs an answer or skip.') + const allowed = new Set((question.options ?? []).map((option) => option.label)) + if (answer.selected.some((label) => !allowed.has(label))) { + throw new Error('Answer contains an unknown option.') + } + if (!question.multiSelect && answer.selected.length > 1) { + throw new Error('Only one option can be selected.') + } + } +} + +function waitFor(milliseconds: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) return resolve() + const timeout = setTimeout(done, milliseconds) + function done(): void { + clearTimeout(timeout) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index bd2c65f8..4b7d7219 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -23,10 +23,10 @@ export function renderMobilePage({ locale }: MobilePageOptions): string { button,input,textarea,select{font:inherit}.shell{max-width:760px;height:var(--app-height,100dvh);margin:auto;padding:calc(6px + env(safe-area-inset-top)) 14px calc(8px + env(safe-area-inset-bottom));display:flex;flex-direction:column} header{height:48px;display:flex;flex:none;align-items:center;justify-content:space-between;padding:0 3px}.brand{font-size:15px;font-weight:600;letter-spacing:-.01em;display:flex;align-items:center;gap:8px}.brand img{width:35px;height:20px;object-fit:contain}.brand .dark-logo{display:none}@media(prefers-color-scheme:dark){.brand .light-logo{display:none}.brand .dark-logo{display:block}}.status{width:8px;height:8px;border-radius:50%;margin-right:4px;background:#35a867;box-shadow:0 0 0 0 rgba(53,168,103,.42);animation:connectedPulse 1.8s ease-out infinite}.status.connecting{background:var(--muted);animation:none}.status.error-state{background:#e34d59;animation:none}@keyframes connectedPulse{70%{box-shadow:0 0 0 7px rgba(53,168,103,0)}100%{box-shadow:0 0 0 0 rgba(53,168,103,0)}} .view{display:none;min-height:0}.view.active{display:flex;flex:1;flex-direction:column}.toolbar{display:flex;flex:none;gap:8px;align-items:center;padding:12px 0}.toolbar select{flex:1;min-width:0}#sessionsView{padding-top:2px}.session-hero{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:7px 2px 11px}.session-heading{min-width:0}.session-heading h1{margin:0;font-size:24px;line-height:1.15;letter-spacing:-.035em;font-weight:650}.session-heading p{margin:0 0 1px;color:var(--muted);font-size:10.5px}.workspace-panel{flex:none;border:1px solid var(--line);border-radius:14px;background:var(--surface);padding:9px}.workspace-label{display:block;margin:0 2px 4px;color:var(--muted);font-size:10px;font-weight:600;letter-spacing:.08em;text-transform:uppercase}.session-actions{display:flex;gap:7px}.session-actions select{flex:1;min-width:0;height:39px;background:var(--card);font-weight:500}.icon-button{display:grid;place-items:center;width:39px;height:39px;padding:0;background:var(--card)}.icon-button.refreshing svg{animation:spin .65s linear infinite}.new-session{display:flex;align-items:center;gap:5px;height:36px;flex:none;border-color:var(--ink);border-radius:11px;padding:6px 11px;background:var(--ink);color:var(--paper);font-weight:600}.new-session:hover{background:var(--ink);opacity:.86}.new-session:disabled{cursor:default;opacity:.35}.new-session svg{display:block}.workspace-hint{color:var(--muted);font-size:12px;line-height:1.4;padding:7px 2px 1px}.workspace-hint[hidden]{display:none}.list-heading{display:flex;align-items:center;justify-content:space-between;padding:14px 2px 6px}.list-heading strong{font-size:13px;font-weight:650}.session-count{display:grid;place-items:center;min-width:24px;height:20px;border-radius:999px;background:var(--surface);color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}body.chat-open header{display:none}body.chat-open .shell{padding:env(safe-area-inset-top) 14px 0}#chatView{position:relative}.chat-toolbar{position:absolute;inset:0 0 auto;z-index:2;min-height:40px;padding:0;background:transparent;pointer-events:none}.back{display:grid;place-items:center;flex:none;width:40px;height:40px;border:1px solid var(--line)!important;border-radius:50%;padding:0!important;background:var(--card)!important;box-shadow:0 2px 8px rgba(0,0,0,.06);pointer-events:auto}.back:hover{background:var(--hover)!important}.back svg{display:block} - select,button,textarea{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px 12px}button{cursor:pointer}button:hover{background:var(--hover)}button.primary{display:grid;place-items:center;flex:none;background:var(--brand);color:white;border-color:var(--brand);border-radius:50%;width:42px;height:42px;padding:0}button.quiet{background:transparent} + select,button,textarea{border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:10px 12px}button{cursor:pointer}button:hover{background:var(--hover)}button.primary{display:flex;align-items:center;justify-content:center;gap:6px;flex:none;min-width:68px;height:40px;border:1px solid var(--ink);border-radius:13px;padding:0 11px 0 13px;background:var(--ink);color:var(--paper);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--paper) 10%,transparent),0 2px 5px rgba(0,0,0,.14);transition:transform .14s ease,opacity .14s ease,box-shadow .14s ease}.primary svg{display:block;flex:none}.primary:hover:not(:disabled){background:var(--ink);opacity:.88;transform:translateY(-1px)}.primary:active:not(:disabled){transform:translateY(0);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--paper) 8%,transparent),0 1px 2px rgba(0,0,0,.12)}.primary:disabled{cursor:default;border-color:var(--line);background:var(--surface);color:var(--muted);box-shadow:none}button.quiet{background:transparent} .list{display:grid;grid-template-columns:minmax(0,1fr);min-width:0;align-content:start;gap:8px;overflow-y:auto;padding:0 0 12px}.row{width:100%;min-width:0;display:flex;align-items:center;gap:11px;text-align:left;background:var(--card);border:1px solid var(--line);border-radius:15px;padding:12px;transition:background .16s ease,transform .16s ease}.row:hover,.row:active{background:var(--surface);transform:translateY(-1px)}.session-mark{display:grid;place-items:center;flex:none;width:34px;height:34px;border-radius:11px;background:var(--surface);color:var(--ink)}.row-copy{min-width:0;flex:1}.row strong{display:block;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.row time{display:block;margin-top:2px;color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}.row-chevron{flex:none;color:var(--muted);opacity:.7}.empty{display:flex;min-height:190px;align-items:center;justify-content:center;flex-direction:column;padding:42px 18px;text-align:center;color:var(--muted)}.empty-mark{display:grid;place-items:center;width:44px;height:44px;margin-bottom:12px;border-radius:14px;background:var(--surface);color:var(--ink)}.empty strong{color:var(--ink);font-size:14px}.empty span:last-child{max-width:220px;margin-top:4px;font-size:12px}.messages{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:0;padding:18px 4px}.chat-open .messages{padding-top:4px;padding-bottom:84px}.message{max-width:100%;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);line-height:1.65}.message.user{align-self:flex-end;max-width:82%;background:var(--hover);border-radius:22px;padding:9px 15px;margin:8px 0 14px}.message.assistant{align-self:stretch;padding:0 4px;margin:0}.message .role{display:none} - .markdown{white-space:normal}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown p{margin:0 0 10px}.markdown h1,.markdown h2,.markdown h3{line-height:1.3;margin:18px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:19px}.markdown h3{font-size:16px}.markdown ul{margin:8px 0;padding-left:22px}.markdown pre{overflow:auto;background:var(--hover);border:1px solid var(--line);border-radius:10px;padding:12px;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown code{background:var(--hover);border-radius:5px;padding:2px 5px;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.markdown pre code{padding:0;background:none}.markdown a{color:var(--brand)}.table-wrap{width:100%;overflow-x:auto;margin:10px 0;-webkit-overflow-scrolling:touch}.markdown table{width:100%;min-width:420px;border-collapse:collapse;font-size:13px;line-height:1.5}.markdown th,.markdown td{padding:9px 10px;border:1px solid var(--line);text-align:left;vertical-align:top}.markdown th{background:var(--hover);font-weight:600}.thinking,.tool{border:1px solid var(--line);border-radius:8px;background:var(--card);margin:2px 0;white-space:normal}.thinking summary,.tool summary{cursor:pointer;list-style:none;padding:7px 10px;color:var(--muted);font-size:12px;line-height:20px;display:flex;align-items:center;justify-content:space-between;gap:10px}.thinking summary::-webkit-details-marker,.tool summary::-webkit-details-marker{display:none}.thinking-body,.tool-body{border-top:1px solid var(--line);padding:9px 10px;color:var(--muted);font-size:12px}.tool-name{color:var(--ink);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.tool-status.error{color:#e34d59}.tool pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 0;background:var(--hover);border-radius:7px;padding:8px}.streaming:after{content:' ';display:inline-block;width:6px;height:15px;margin-left:2px;vertical-align:-2px;background:var(--brand);animation:pulse .7s infinite alternate} - .composer{position:absolute;z-index:2;inset:auto 0 0;padding:10px 0 4px;background:transparent;pointer-events:none}.composer-inner{display:flex;gap:8px;align-items:flex-end;border:1px solid var(--line);border-radius:20px;padding:8px 8px 8px 14px;background:var(--card);box-shadow:0 4px 16px rgba(0,0,0,.05);pointer-events:auto}.composer textarea{flex:1;min-height:42px;max-height:150px;resize:none;border:0;outline:0;border-radius:0;padding:10px 0;background:transparent;font-size:16px}.primary.cancel{background:var(--ink);border-color:var(--ink)}.primary[hidden]{display:none}.error{color:#e34d59;font-size:13px;margin:8px 0}.toast{position:fixed;left:50%;top:calc(64px + env(safe-area-inset-top));z-index:5;transform:translate(-50%,-8px);background:var(--ink);color:var(--paper);border-radius:999px;padding:6px 11px;font-size:12px;opacity:0;pointer-events:none;transition:.18s ease}.toast.show{opacity:1;transform:translate(-50%,0)}.loading{display:grid;gap:12px;padding:22px 4px}.skeleton{height:14px;border-radius:7px;background:var(--hover);animation:pulse 1.1s ease-in-out infinite alternate}.skeleton:nth-child(2){width:82%}.skeleton:nth-child(3){width:64%}@keyframes pulse{to{opacity:.35}}@keyframes spin{to{transform:rotate(360deg)}}@media(display-mode:standalone){.chat-open .messages{padding-bottom:calc(84px + env(safe-area-inset-bottom))}.composer{padding-bottom:calc(4px + env(safe-area-inset-bottom))}}@media(prefers-reduced-motion:reduce){.skeleton,.status,.icon-button.refreshing svg{animation:none}} + .markdown{white-space:normal}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown p{margin:0 0 10px}.markdown h1,.markdown h2,.markdown h3{line-height:1.3;margin:18px 0 8px}.markdown h1{font-size:22px}.markdown h2{font-size:19px}.markdown h3{font-size:16px}.markdown ul{margin:8px 0;padding-left:22px}.markdown pre{overflow:auto;background:var(--hover);border:1px solid var(--line);border-radius:10px;padding:12px;font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown code{background:var(--hover);border-radius:5px;padding:2px 5px;font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.markdown pre code{padding:0;background:none}.markdown a{color:var(--brand)}.table-wrap{width:100%;overflow-x:auto;margin:10px 0;-webkit-overflow-scrolling:touch}.markdown table{width:100%;min-width:420px;border-collapse:collapse;font-size:13px;line-height:1.5}.markdown th,.markdown td{padding:9px 10px;border:1px solid var(--line);text-align:left;vertical-align:top}.markdown th{background:var(--hover);font-weight:600}.thinking,.tool{position:relative;margin:0;white-space:normal}.thinking summary,.tool summary{position:relative;min-height:34px;cursor:pointer;list-style:none;border-radius:6px;padding:5px 2px;color:var(--ink);font-size:14px;line-height:24px;display:flex;align-items:center;gap:0;overflow:hidden}.thinking summary:hover,.tool summary:hover{background:var(--hover)}.thinking summary::-webkit-details-marker,.tool summary::-webkit-details-marker{display:none}.activity-leading{width:16px;height:16px;display:grid;place-items:center;flex:none;margin-right:6px;color:var(--muted)}.activity-leading svg{display:block}.activity-title{flex:none;font-weight:400}.activity-dot{width:2px;height:2px;margin:0 8px;border-radius:50%;background:var(--muted);opacity:.7;flex:none}.activity-summary{position:relative;min-width:0;flex:1;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.activity-chevron{width:16px;height:16px;display:grid;place-items:center;flex:none;color:var(--muted);transition:transform .14s ease}.thinking[open] .activity-chevron,.tool[open] .activity-chevron{transform:rotate(90deg)}.thinking-body{padding:3px 0 7px 22px;color:var(--muted);font-size:13px;line-height:22px}.tool-body{padding:3px 0 8px 20px;color:var(--muted);font-size:12px}.tool-body strong{display:block;margin:7px 0 3px;font-weight:500}.tool-status.error,.tool[data-state=error] .activity-leading{color:#e34d59}.tool pre{max-height:260px;white-space:pre-wrap;overflow:auto;overflow-wrap:anywhere;margin:0 0 4px;border:1px solid var(--line);background:var(--surface);color:var(--ink);border-radius:10px;padding:10px 12px;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace}.thinking[data-state=running] .activity-summary:after,.tool[data-state=running] .activity-summary:after{content:'';position:absolute;inset:0 auto 0 -240px;width:240px;pointer-events:none;background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--paper) 72%,transparent),transparent);animation:activitySweep 2.6s ease-out infinite}.streaming:after{content:' ';display:inline-block;width:6px;height:15px;margin-left:2px;vertical-align:-2px;background:var(--brand);animation:pulse .7s infinite alternate}.turn-status{height:26px;display:inline-flex;align-items:center;margin:0 4px 6px;pointer-events:none;color:transparent;-webkit-text-fill-color:transparent;background:linear-gradient(90deg,var(--brand) 0%,var(--brand) 40%,color-mix(in srgb,var(--brand) 35%,var(--paper)) 50%,var(--brand) 60%,var(--brand) 100%);background-position:100% 0;background-size:250% 100%;-webkit-background-clip:text;background-clip:text;font-size:14px;font-weight:600;white-space:nowrap;animation:deepDiveShimmer 1.8s linear infinite} + [hidden]{display:none!important}.message:empty{display:none}.composer{position:absolute;z-index:2;inset:auto 0 0;padding:10px 0 5px;background:transparent;pointer-events:none}.composer-inner{min-height:58px;display:flex;gap:8px;align-items:flex-end;border:1px solid var(--line);border-radius:23px;padding:8px 8px 8px 16px;background:var(--card);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--paper) 70%,transparent),0 2px 8px rgba(0,0,0,.06),0 14px 34px rgba(0,0,0,.06);pointer-events:auto;transition:border-color .16s ease,box-shadow .16s ease,transform .16s ease}.composer-inner:focus-within{border-color:color-mix(in srgb,var(--ink) 28%,var(--line));box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--paper) 72%,transparent),0 3px 10px rgba(0,0,0,.08),0 18px 38px rgba(0,0,0,.08);transform:translateY(-1px)}.composer textarea{flex:1;height:40px;min-height:40px;max-height:150px;overflow-y:auto;resize:none;border:0;outline:0;border-radius:0;padding:9px 0;background:transparent;color:var(--ink);caret-color:var(--brand);font-size:16px;letter-spacing:-.01em}.composer textarea::placeholder{color:var(--muted);opacity:.68}.composer .primary,.primary.cancel{width:40px;min-width:40px;border-radius:50%;padding:0;background:var(--ink);border-color:var(--ink)}.settings-trigger{display:grid;place-items:center;width:40px;min-width:40px;height:40px;border-radius:50%;padding:0;background:var(--surface);color:var(--muted)}.settings-trigger[aria-expanded=true]{border-color:var(--ink);background:var(--ink);color:var(--paper)}.session-settings{position:absolute;right:8px;bottom:76px;width:min(350px,calc(100vw - 28px));max-height:min(62vh,470px);overflow-y:auto;border:1px solid var(--line);border-radius:18px;padding:15px;background:var(--card);box-shadow:0 8px 26px rgba(0,0,0,.1),0 24px 60px rgba(0,0,0,.12);pointer-events:auto}.settings-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.settings-head strong{font-size:15px;font-weight:650}.settings-close{display:grid;place-items:center;width:28px;height:28px;border:0;border-radius:50%;padding:0;background:transparent;color:var(--muted);font-size:20px;line-height:1}.setting-field{display:block;margin-top:11px}.setting-field>span{display:block;margin:0 2px 5px;color:var(--muted);font-size:11px;font-weight:600}.setting-field select{width:100%;height:42px;background:var(--surface)}.setting-field select:disabled{opacity:.55}.setting-note{margin:5px 2px 0;color:var(--muted);font-size:11px;line-height:1.4}.settings-error{min-height:17px;margin-top:7px;color:#e34d59;font-size:11px}.settings-loading{padding:14px 2px;color:var(--muted);font-size:12px}.todo-dock{margin:0 0 8px;border:1px solid var(--line);border-radius:14px;background:var(--surface);overflow:hidden;pointer-events:auto}.todo-header{width:100%;height:38px;display:flex;align-items:center;gap:8px;border:0;border-radius:0;padding:0 11px;background:transparent;text-align:left}.todo-lead,.todo-chevron{display:grid;place-items:center;flex:none;color:var(--muted)}.todo-title{flex:none;font-size:13px;font-weight:600}.todo-progress{min-width:0;flex:1;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.todo-list{display:grid;gap:7px;max-height:180px;overflow-y:auto;margin:0;padding:1px 12px 10px;list-style:none}.todo-item{min-width:0;display:flex;align-items:center;gap:9px;color:var(--muted);font-size:13px}.todo-glyph{display:grid;place-items:center;width:16px;height:16px;flex:none}.todo-item[data-status=completed] .todo-glyph{color:#35a867}.todo-item[data-status=in_progress] .todo-glyph{color:var(--brand);animation:spin 1s linear infinite}.todo-content{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.turn-running .messages{padding-bottom:116px}.todo-open .messages{padding-bottom:130px}.turn-running.todo-open .messages{padding-bottom:162px}.todo-expanded .messages{padding-bottom:min(42vh,300px)}.turn-running.todo-expanded .messages{padding-bottom:min(42vh,332px)}.turn-running .session-settings{bottom:108px}.todo-open .session-settings{bottom:122px}.turn-running.todo-open .session-settings{bottom:154px}.todo-expanded .session-settings{bottom:min(42vh,292px)}.turn-running.todo-expanded .session-settings{bottom:min(42vh,324px)}.primary[hidden]{display:none}.question-composer{position:absolute;z-index:3;inset:auto 0 0;padding:10px 0 5px;pointer-events:none}.question-shell{max-height:min(70vh,580px);display:flex;flex-direction:column;border:1px solid var(--line);border-radius:23px;background:var(--card);box-shadow:0 4px 16px rgba(0,0,0,.08),0 22px 54px rgba(0,0,0,.1);overflow:hidden;pointer-events:auto}.question-top{display:flex;align-items:center;gap:9px;flex:none;padding:13px 16px 10px;border-bottom:1px solid var(--line)}.question-mark{display:grid;place-items:center;width:24px;height:24px;border-radius:8px;background:var(--surface);color:var(--ink)}.question-context{min-width:0;flex:1}.question-context strong{display:block;font-size:13px;font-weight:650}.question-context span{display:block;color:var(--muted);font-size:11px}.question-close{width:30px;height:30px;display:grid;place-items:center;border:0;border-radius:50%;padding:0;background:transparent;color:var(--muted)}.question-body{min-height:0;overflow-y:auto;padding:14px 16px 4px;-webkit-overflow-scrolling:touch}.question-title{margin:0;font-size:17px;line-height:1.45;letter-spacing:-.015em;font-weight:620}.question-detail{margin-top:7px;color:var(--muted);font-size:13px}.question-options{display:grid;gap:7px;margin-top:13px}.question-option{width:100%;display:flex;align-items:flex-start;gap:10px;border:1px solid var(--line);border-radius:13px;padding:10px 11px;background:var(--card);text-align:left}.question-option.selected{border-color:var(--ink);background:var(--surface)}.option-control{display:grid;place-items:center;width:19px;height:19px;flex:none;margin-top:1px;border:1px solid var(--muted);border-radius:50%;font-size:11px}.multi .option-control{border-radius:6px}.question-option.selected .option-control{border-color:var(--ink);background:var(--ink);color:var(--paper)}.option-copy{min-width:0;flex:1}.option-label{display:flex;align-items:center;flex-wrap:wrap;gap:6px;font-size:14px}.recommendation{border-radius:999px;background:var(--surface);color:var(--muted);padding:1px 6px;font-size:10px}.option-description{display:block;margin-top:2px;color:var(--muted);font-size:12px;line-height:1.45}.custom-label{display:block;margin:13px 1px 5px;color:var(--muted);font-size:11px;font-weight:600}.question-custom{width:100%;min-height:54px;max-height:110px;resize:vertical;border:1px solid var(--line);border-radius:13px;padding:10px 11px;background:var(--surface);outline:none}.question-custom:focus{border-color:color-mix(in srgb,var(--ink) 35%,var(--line))}.question-error{min-height:18px;padding:5px 16px 0;color:#e34d59;font-size:11px}.question-actions{display:flex;align-items:center;gap:7px;flex:none;padding:8px 12px 12px}.question-actions button{height:38px;padding:0 12px;border-radius:11px}.question-actions .spacer{flex:1}.question-actions .submit-answer{border-color:var(--ink);background:var(--ink);color:var(--paper);font-weight:600}.question-actions button:disabled{opacity:.45;cursor:default}.question-open .messages{padding-bottom:min(64vh,520px)}.error{color:#e34d59;font-size:13px;margin:8px 0}.toast{position:fixed;left:50%;top:calc(64px + env(safe-area-inset-top));z-index:5;transform:translate(-50%,-8px);background:var(--ink);color:var(--paper);border-radius:999px;padding:6px 11px;font-size:12px;opacity:0;pointer-events:none;transition:.18s ease}.toast.show{opacity:1;transform:translate(-50%,0)}.loading{display:grid;gap:12px;padding:22px 4px}.skeleton{height:14px;border-radius:7px;background:var(--hover);animation:pulse 1.1s ease-in-out infinite alternate}.skeleton:nth-child(2){width:82%}.skeleton:nth-child(3){width:64%}@keyframes pulse{to{opacity:.35}}@keyframes spin{to{transform:rotate(360deg)}}@keyframes activitySweep{0%{left:-240px}90%,to{left:100%}}@keyframes deepDiveShimmer{to{background-position:0 0}}@media(display-mode:standalone){.chat-open .messages{padding-bottom:calc(84px + env(safe-area-inset-bottom))}.chat-open.turn-running .messages{padding-bottom:calc(116px + env(safe-area-inset-bottom))}.chat-open.todo-open .messages{padding-bottom:calc(130px + env(safe-area-inset-bottom))}.chat-open.turn-running.todo-open .messages{padding-bottom:calc(162px + env(safe-area-inset-bottom))}.chat-open.todo-expanded .messages{padding-bottom:min(42vh,calc(300px + env(safe-area-inset-bottom)))}.chat-open.turn-running.todo-expanded .messages{padding-bottom:min(42vh,calc(332px + env(safe-area-inset-bottom)))}.chat-open.question-open .messages{padding-bottom:min(64vh,520px)}.composer,.question-composer{padding-bottom:calc(5px + env(safe-area-inset-bottom))}}@media(prefers-reduced-motion:reduce){.skeleton,.status,.icon-button.refreshing svg,.thinking[data-state=running] .activity-summary:after,.tool[data-state=running] .activity-summary:after,.turn-status,.todo-item[data-status=in_progress] .todo-glyph{animation:none}.composer-inner,.primary{transition:none}}
@@ -41,7 +41,8 @@ export function renderMobilePage({ locale }: MobilePageOptions): string {
-
+ +