diff --git a/.gitattributes b/.gitattributes
index c2c7c4a..bf7f649 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,3 +4,13 @@
*.icns binary
*.woff2 binary
docker/boite-server text eol=lf
+
+# Keep language statistics focused on TypeScript, Svelte and Rust.
+# Supporting styles, entry points and scripts remain visible in diffs.
+*.css -linguist-detectable
+*.html -linguist-detectable
+*.js -linguist-detectable
+*.ps1 -linguist-detectable
+*.sh -linguist-detectable
+Dockerfile -linguist-detectable
+docker/boite-server -linguist-detectable
diff --git a/docs/server.md b/docs/server.md
index 90c64b2..baf0d16 100644
--- a/docs/server.md
+++ b/docs/server.md
@@ -95,6 +95,18 @@ The core does not terminate TLS. Public access needs an HTTPS reverse proxy
that forwards WebSocket upgrades and preserves `Host` and `Origin`. Serve the
UI and `/rpc` from the same origin. Do not expose plain HTTP to the internet.
+Before connecting through the proxy, add its exact browser origin, such as
+`https://boite.example.com`, to the core's `browserOrigins` setting. Connect the
+desktop shell directly as an owner, select that machine, open Machines > Allowed
+browser origins, and configure the origins there. Keep any existing origins that are
+still needed. Origins contain a scheme, hostname and optional port, with no
+path or trailing slash. See [machine connections](machines.md).
+
+Preserving the proxy headers alone is not enough: an HTTPS origin on port 443
+differs from the core listening on port 7337. An origin that is not allowed gets
+HTTP 403 on `/rpc`, even when the pairing token is valid. Keep the allowlist
+explicit; do not strip the `Origin` header to bypass this check.
+
### Image verification
From a Linux checkout with Docker:
diff --git a/packages/core/src/activity.ts b/packages/core/src/activity.ts
index 7361559..d516b0a 100644
--- a/packages/core/src/activity.ts
+++ b/packages/core/src/activity.ts
@@ -16,13 +16,9 @@ export class ActivityStore {
for (const thread of core.journal.listThreads()) {
const saved = core.journal.getSetting(`activity:${thread.id}`) as ThreadActivity | undefined;
if (!saved) continue;
- for (const kind of ['goal', 'loop'] as const) {
- const item = saved[kind];
- if (item?.status === 'active') { item.status = 'paused'; item.error = 'Core restarted. Resume to continue.'; }
- }
- if (saved.loop) saved.loop.nextRunAt = null;
+ const changed = pauseActivity(saved, 'Core restarted. Resume to continue.');
this.states.set(thread.id, saved);
- this.save(thread.id);
+ if (changed) this.save(thread.id);
}
core.bus.onAny((name, payload) => {
if (this.closed) return;
@@ -103,12 +99,7 @@ export class ActivityStore {
const state = this.get(threadId);
let createdId: string | null = null;
if (part.name.toLowerCase() === 'taskcreate') {
- try {
- const output = JSON.parse(part.output ?? 'null');
- const id = output?.task?.id ?? output?.taskId ?? output?.id;
- if (typeof id === 'string' || typeof id === 'number') createdId = String(id);
- } catch { /* Some Claude versions return a human-readable result. */ }
- createdId ??= /Task\s+#?(\d+)\s+created/i.exec(part.output ?? '')?.[1] ?? null;
+ createdId = createdTaskId(part.output);
if (!createdId) return;
}
const id = String(input.taskId ?? createdId);
@@ -128,16 +119,28 @@ export class ActivityStore {
const state = this.states.get(turn.threadId);
if (!state) return;
if (owned?.kind === 'goal' && owned.generation === (this.generations.get(turn.threadId) ?? 0) && state.goal?.status === 'active') {
- const messages = this.core.threads.get(turn.threadId).messages;
- const completed = messages.some((message) => message.turnId === turn.id && message.role === 'assistant' && message.parts.some((part) => part.type === 'text' && /^\s*\[BOITE_GOAL_COMPLETE\]\s*$/m.test(part.text)));
- const blocked = messages.some((message) => message.turnId === turn.id && message.role === 'assistant' && message.parts.some((part) => part.type === 'text' && /^\s*\[BOITE_GOAL_BLOCKED\]\s*$/m.test(part.text)));
- if (blocked) { state.goal.status = 'paused'; state.goal.error = 'The agent reported a blocker. Read its answer before resuming.'; this.save(turn.threadId); }
- else if (completed) { state.goal.status = 'complete'; this.save(turn.threadId); }
+ const signal = this.goalResult(turn);
+ if (signal === 'blocked') { state.goal.status = 'paused'; state.goal.error = 'The agent reported a blocker. Read its answer before resuming.'; this.save(turn.threadId); }
+ else if (signal === 'complete') { state.goal.status = 'complete'; this.save(turn.threadId); }
}
// Let the scheduler release its running slot and clients submit queued user input first.
this.schedule(turn.threadId, 250);
}
+ private goalResult(turn: Turn): 'complete' | 'blocked' | null {
+ let result: 'complete' | null = null;
+ for (const message of this.core.journal.walkTurnMessages(turn.threadId, turn.id)) {
+ if (message.role !== 'assistant') continue;
+ for (const part of message.parts) {
+ if (part.type !== 'text') continue;
+ const signal = goalSignal(part.text);
+ if (signal === 'blocked') return signal;
+ if (signal === 'complete') result = signal;
+ }
+ }
+ return result;
+ }
+
private run(threadId: string): void {
if (this.closed) return;
const thread = this.core.journal.getThread(threadId);
@@ -150,7 +153,7 @@ export class ActivityStore {
return;
}
const prompt = kind === 'goal'
- ? `Work toward this goal: ${state.goal!.objective}\nContinue until the objective is achieved. When you have verified completion, write [BOITE_GOAL_COMPLETE] alone on its own line. If blocked or waiting for user input, explain what is missing and write [BOITE_GOAL_BLOCKED] alone on its own line.`
+ ? `Work toward this goal: ${state.goal!.objective}\nContinue until the objective is achieved. When you have verified completion, end your answer with [BOITE_GOAL_COMPLETE] alone on its own line, outside code blocks. If blocked or waiting for user input, explain what is missing and end with [BOITE_GOAL_BLOCKED] alone on its own line, outside code blocks.`
: state.loop!.prompt;
try {
const turn = this.core.threads.startTurn(threadId, prompt);
@@ -177,9 +180,7 @@ export class ActivityStore {
const state = this.states.get(threadId);
if (!state) return;
this.clearTimer(threadId);
- for (const kind of ['goal', 'loop'] as const) { const item = state[kind]; if (item?.status === 'active') { item.status = 'paused'; item.error = error; } }
- if (state.loop) state.loop.nextRunAt = null;
- this.save(threadId);
+ if (pauseActivity(state, error)) this.save(threadId);
}
private save(threadId: string): void {
@@ -192,6 +193,49 @@ export class ActivityStore {
}
export function taskStatus(value: unknown): AgentTask['status'] { return value === 'completed' ? 'completed' : value === 'in_progress' || value === 'inProgress' ? 'in_progress' : 'pending'; }
+
+function createdTaskId(text: string | null): string | null {
+ try {
+ const output = JSON.parse(text ?? 'null');
+ const id = output?.task?.id ?? output?.taskId ?? output?.id;
+ if (typeof id === 'string' || typeof id === 'number') return String(id);
+ } catch { /* Older agents return a human-readable confirmation. */ }
+ return /Task\s+#?(\d+)\s+created/i.exec(text ?? '')?.[1] ?? null;
+}
+
+function pauseActivity(state: ThreadActivity, error: string | null): boolean {
+ let changed = false;
+ for (const item of [state.goal, state.loop]) {
+ if (item?.status !== 'active') continue;
+ item.status = 'paused';
+ item.error = error;
+ changed = true;
+ }
+ if (state.loop && state.loop.nextRunAt !== null) {
+ state.loop.nextRunAt = null;
+ changed = true;
+ }
+ return changed;
+}
+
+/** Only a final standalone marker outside fenced or indented code is a signal. */
+export function goalSignal(text: string): 'complete' | 'blocked' | null {
+ let fence: string | null = null;
+ let last = '';
+ for (const line of text.split(/\r?\n/)) {
+ const mark = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
+ if (fence !== null) {
+ if (mark && mark[1]![0] === fence[0] && mark[1]!.length >= fence.length && mark[2]!.trim() === '') fence = null;
+ if (line.trim()) last = '';
+ continue;
+ }
+ if (mark) { fence = mark[1]!; last = ''; continue; }
+ if (line.trim()) last = line;
+ }
+ if (/^ {0,3}\[BOITE_GOAL_COMPLETE\][ \t]*$/.test(last)) return 'complete';
+ if (/^ {0,3}\[BOITE_GOAL_BLOCKED\][ \t]*$/.test(last)) return 'blocked';
+ return null;
+}
export function normalizeTasks(items: unknown[]): AgentTask[] {
return items.flatMap((value, index) => {
if (!value || typeof value !== 'object') return [];
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 7a62c4f..af66570 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -12,6 +12,8 @@ import type {
export interface ConnectOptions {
client?: { name: string; version: string };
timeoutMs?: number;
+ /** Response deadline for ordinary RPC calls; the handshake uses timeoutMs. */
+ requestTimeoutMs?: number;
/** Say hello with a pairing grant instead of the token; `session` then carries what came back. */
grant?: string;
}
@@ -52,18 +54,28 @@ function socketUrl(url: string): string {
export async function connect(url: string, token: string, options: ConnectOptions = {}): Promise {
const timeoutMs = options.timeoutMs ?? 5000;
+ const deadline = Date.now() + timeoutMs;
const socket = new WebSocket(socketUrl(url));
const pending = new Map();
const listeners = new Map void>>();
let nextId = 1;
let closed = false;
- const send = (method: string, params: unknown): Promise => {
+ const send = (method: string, params: unknown, waitMs = options.requestTimeoutMs ?? 120_000): Promise => {
const id = nextId;
nextId += 1;
return new Promise((resolve, reject) => {
- pending.set(id, { resolve, reject });
- socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
+ const timer = setTimeout(() => {
+ pending.delete(id);
+ reject(new Error(`timed out waiting for ${method}`));
+ }, waitMs);
+ const waiter: Pending = {
+ resolve(value) { clearTimeout(timer); resolve(value); },
+ reject(error) { clearTimeout(timer); reject(error); },
+ };
+ pending.set(id, waiter);
+ try { socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); }
+ catch (error) { pending.delete(id); waiter.reject(error as Error); }
});
};
@@ -75,6 +87,7 @@ export async function connect(url: string, token: string, options: ConnectOption
} catch {
return;
}
+ if (frame === null || typeof frame !== 'object') return;
if (typeof frame.id === 'number') {
const waiter = pending.get(frame.id);
if (waiter === undefined) return;
@@ -95,7 +108,7 @@ export async function connect(url: string, token: string, options: ConnectOption
});
await new Promise((resolve, reject) => {
- const timer = setTimeout(() => reject(new Error('the socket did not open')), timeoutMs);
+ const timer = setTimeout(() => reject(new Error('timed out opening the socket')), timeoutMs);
socket.addEventListener('open', () => {
clearTimeout(timer);
resolve();
@@ -104,13 +117,17 @@ export async function connect(url: string, token: string, options: ConnectOption
clearTimeout(timer);
reject(new Error('the socket failed to open'));
});
- });
+ socket.addEventListener('close', () => {
+ clearTimeout(timer);
+ reject(new Error('the socket closed before opening'));
+ }, { once: true });
+ }).catch(error => { socket.close(); throw error; });
const hello = (await send('hello', {
...(options.grant === undefined ? { token } : { grant: options.grant }),
protocolVersion: PROTOCOL_VERSION,
client: options.client ?? { name: 'test', version: '2.0.0-beta.1' },
- })) as { core: CoreInfo; principal: Principal; session?: { id: string; token: string } };
+ }, Math.max(1, deadline - Date.now())).catch(error => { socket.close(); throw error; })) as { core: CoreInfo; principal: Principal; session?: { id: string; token: string } };
if (hello.core.protocolVersion !== PROTOCOL_VERSION) {
socket.close();
throw new Error(`core protocol version must be ${PROTOCOL_VERSION}`);
diff --git a/packages/core/src/drivers/claude.ts b/packages/core/src/drivers/claude.ts
index 23c426d..3097bcd 100644
--- a/packages/core/src/drivers/claude.ts
+++ b/packages/core/src/drivers/claude.ts
@@ -370,13 +370,7 @@ class ClaudeTurn {
case 'content_block_delta': {
// `signature_delta` signs the thinking block; it is not text.
if (event.delta?.type === 'thinking_delta') {
- const thinking = event.delta.thinking ?? '';
- if (thinking.length === 0) break;
- const seen = event.index === undefined ? undefined : this.thinkingBlocks.get(event.index);
- const at = seen ?? this.openThinking();
- if (event.index !== undefined) this.thinkingBlocks.set(event.index, at);
- this.write(at, thinking);
- this.streamedThinking.set(this.apiMessageId, (this.streamedThinking.get(this.apiMessageId) ?? '') + thinking);
+ this.streamContent('thinking', event.index, event.delta.thinking ?? '');
break;
}
if (event.delta?.type === 'input_json_delta') {
@@ -390,13 +384,7 @@ class ClaudeTurn {
break;
}
if (event.delta?.type !== 'text_delta') break;
- const text = event.delta.text ?? '';
- if (text.length === 0) break;
- const known = event.index === undefined ? undefined : this.textBlocks.get(event.index);
- const index = known ?? this.openText();
- if (event.index !== undefined) this.textBlocks.set(event.index, index);
- this.write(index, text);
- this.streamedText.set(this.apiMessageId, (this.streamedText.get(this.apiMessageId) ?? '') + text);
+ this.streamContent('text', event.index, event.delta.text ?? '');
break;
}
default:
@@ -404,6 +392,17 @@ class ClaudeTurn {
}
}
+ private streamContent(kind: 'text' | 'thinking', blockIndex: number | undefined, text: string): void {
+ if (!text.length) return;
+ const blocks = kind === 'text' ? this.textBlocks : this.thinkingBlocks;
+ const accumulated = kind === 'text' ? this.streamedText : this.streamedThinking;
+ const known = blockIndex === undefined ? undefined : blocks.get(blockIndex);
+ const index = known ?? (kind === 'text' ? this.openText() : this.openThinking());
+ if (blockIndex !== undefined) blocks.set(blockIndex, index);
+ this.write(index, text);
+ accumulated.set(this.apiMessageId, (accumulated.get(this.apiMessageId) ?? '') + text);
+ }
+
private handleAssistant(message: SDKAssistantMessage): void {
if (message.error !== undefined) {
this.fail(errorSentence(message.error));
diff --git a/packages/core/src/journal.ts b/packages/core/src/journal.ts
index a07d00a..5b117e2 100644
--- a/packages/core/src/journal.ts
+++ b/packages/core/src/journal.ts
@@ -424,6 +424,7 @@ export class Journal {
this.db.exec('PRAGMA busy_timeout = 5000');
this.db.transaction(() => migrate(this.db))();
this.db.exec('CREATE INDEX IF NOT EXISTS turns_by_status ON turns (status)');
+ this.db.exec('CREATE INDEX IF NOT EXISTS messages_by_turn ON messages (thread_id, turn_id)');
}
append(event: JournalEvent, apply: (db: Database) => T): T {
@@ -684,11 +685,27 @@ export class Journal {
return rows.map(toMessage);
}
+ lastUserMessage(threadId: string, turnId: string): Message | null {
+ const row = this.db.query("SELECT * FROM messages WHERE thread_id = ? AND turn_id = ? AND role = 'user' ORDER BY rowid DESC LIMIT 1")
+ .get(threadId, turnId) as MessageRow | null;
+ return row === null ? null : toMessage(row);
+ }
+
+ *walkTurnMessages(threadId: string, turnId: string): Iterable {
+ const statement = this.db.prepare('SELECT * FROM messages WHERE thread_id = ? AND turn_id = ? ORDER BY rowid');
+ try {
+ for (const row of statement.iterate(threadId, turnId)) yield toMessage(row as MessageRow);
+ } finally { statement.finalize(); }
+ }
+
/** Stream history for a continuation without loading images from every message at once. */
*walkMessages(threadId: string): Iterable {
- for (const row of this.db.query('SELECT * FROM messages WHERE thread_id = ? ORDER BY rowid').iterate(threadId)) {
- yield toMessage(row as MessageRow);
- }
+ // A caller may stop at its current turn. Do not leave a partially consumed
+ // cached statement for the next continuation to reuse.
+ const statement = this.db.prepare('SELECT * FROM messages WHERE thread_id = ? ORDER BY rowid');
+ try {
+ for (const row of statement.iterate(threadId)) yield toMessage(row as MessageRow);
+ } finally { statement.finalize(); }
}
/**
diff --git a/packages/core/src/threads.ts b/packages/core/src/threads.ts
index 34a898f..b4a322d 100644
--- a/packages/core/src/threads.ts
+++ b/packages/core/src/threads.ts
@@ -1120,10 +1120,8 @@ export class ThreadStore {
/** The user message of the turn, read back from the journal: the text and the images it carried. */
private lastUserInput(threadId: ThreadId, turnId: TurnId): { prompt: string; attachments: ImageAttachment[] } {
- const messages = this.core.journal.listMessages(threadId);
- for (let index = messages.length - 1; index >= 0; index -= 1) {
- const message = messages[index];
- if (message === undefined || message.turnId !== turnId || message.role !== 'user') continue;
+ const message = this.core.journal.lastUserMessage(threadId, turnId);
+ if (message !== null) {
const attachments: ImageAttachment[] = [];
for (const part of message.parts) {
if (part.type === 'image') {
diff --git a/packages/core/test/activity.test.ts b/packages/core/test/activity.test.ts
index 59f4d04..e349ed3 100644
--- a/packages/core/test/activity.test.ts
+++ b/packages/core/test/activity.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { Core } from '../src/core.ts';
+import { ActivityStore } from '../src/activity.ts';
import { connect } from '../src/client.ts';
import { setDriver } from '../src/drivers/index.ts';
import { echoThread, startTestCore, waitFor, type TestCore } from './harness.ts';
@@ -9,6 +10,62 @@ let restore: (() => void) | undefined;
beforeEach(async () => { h = await startTestCore(); });
afterEach(async () => { restore?.(); restore = undefined; await h.stop(); });
+test.each([
+ ['quoted complete', 'Not finished.\n```\n[BOITE_GOAL_COMPLETE]\n```', 0, 'active'],
+ ['quoted blocked', 'Continuing.\n~~~text\n[BOITE_GOAL_BLOCKED]\n~~~', 0, 'active'],
+ ['indented example', 'Example:\n [BOITE_GOAL_COMPLETE]', 0, 'active'],
+ ['beyond page', '[BOITE_GOAL_COMPLETE]', 120, 'complete'],
+] as const)('goal marker: %s', async (_name, text, extraMessages, expected) => {
+ const client = await h.connect();
+ const { threadId } = await echoThread(h, client);
+ restore = setDriver('echo', {
+ protocol: 'echo', startTurn(ctx) {
+ for (let i = 0; i <= extraMessages; i++) {
+ const id = ctx.emit.startMessage('assistant');
+ ctx.emit.part(id, 0, { type: 'text', text: i === 0 ? text : 'Additional output.' });
+ ctx.emit.complete(id, 'complete');
+ }
+ return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) };
+ },
+ });
+ h.core.activity.set({ threadId, goal: { objective: 'Check completion' } });
+ await waitFor(() => h.core.activity.get(threadId).goal!.iterations > 0 && h.core.threads.get(threadId).status === 'idle');
+ expect(h.core.activity.get(threadId).goal?.status).toBe(expected);
+ h.core.activity.pauseAll(threadId);
+});
+
+test('paused activity produces no new events when loaded or closed again', async () => {
+ const client = await h.connect();
+ const { threadId } = await echoThread(h, client);
+ h.core.activity.set({ threadId, goal: { objective: 'Wait' } });
+ h.core.activity.pauseAll(threadId);
+ const count = () => (h.core.journal.db.query("SELECT COUNT(*) AS n FROM events WHERE type = 'thread.activity'").get() as { n: number }).n;
+ const before = count();
+ h.core.activity.close();
+ expect(count()).toBe(before);
+ const loaded = new ActivityStore(h.core);
+ loaded.close();
+ expect(count()).toBe(before);
+});
+
+test('starting a turn does not decode historical messages to read its prompt', async () => {
+ const client = await h.connect();
+ const { threadId } = await echoThread(h, client);
+ h.core.journal.putMessage({ id: 'history', threadId, turnId: 'old', role: 'assistant', state: 'complete', createdAt: 0, parts: [{ type: 'text', text: 'old'.repeat(1000) }] });
+ const original = h.core.journal.listMessages.bind(h.core.journal);
+ h.core.journal.listMessages = () => { throw new Error('unbounded history read'); };
+ let prompt: string | undefined;
+ restore = setDriver('echo', { protocol: 'echo', startTurn(ctx) {
+ prompt = ctx.prompt;
+ return { stop() {}, done: Promise.resolve({ status: 'done', sessionId: null, usage: null }) };
+ } });
+ try {
+ const turn = h.core.threads.startTurn(threadId, 'current prompt');
+ await waitFor(() => h.core.journal.getTurn(turn.id)?.finishedAt !== null);
+ expect(prompt).toBe('current prompt');
+ } finally { h.core.journal.listMessages = original; }
+});
+
test('goal continues across turns, reports tasks and stops only on completion', async () => {
const client = await h.connect();
const { threadId } = await echoThread(h, client);
diff --git a/packages/core/test/client-timeout.test.ts b/packages/core/test/client-timeout.test.ts
new file mode 100644
index 0000000..60828fa
--- /dev/null
+++ b/packages/core/test/client-timeout.test.ts
@@ -0,0 +1,30 @@
+import { expect, test } from 'bun:test';
+import { PROTOCOL_VERSION } from '@boite/contracts';
+import { connect } from '../src/client.ts';
+
+test.each(['hello', 'call'] as const)('client bounds an unanswered %s and closes a failed handshake', async (phase) => {
+ let closed = false;
+ const server = Bun.serve({
+ hostname: '127.0.0.1', port: 0,
+ fetch(request, self) { return self.upgrade(request) ? undefined : new Response('upgrade required'); },
+ websocket: {
+ message(socket, raw) {
+ const frame = JSON.parse(String(raw));
+ if (phase === 'call' && frame.method === 'hello') socket.send(JSON.stringify({ jsonrpc: '2.0', id: frame.id, result: { core: { protocolVersion: PROTOCOL_VERSION }, principal: 'owner' } }));
+ },
+ close() { closed = true; },
+ },
+ });
+ let client: Awaited> | undefined;
+ const result = (async () => {
+ client = await connect(`http://127.0.0.1:${server.port}`, 'test', { timeoutMs: 25, requestTimeoutMs: 25 });
+ await client.call('settings.get', {});
+ })().then(() => 'resolved', error => String(error));
+ try {
+ expect(await Promise.race([result, Bun.sleep(300).then(() => 'still pending')])).toContain('timed out');
+ if (phase === 'hello') {
+ for (let i = 0; i < 20 && !closed; i++) await Bun.sleep(5);
+ expect(closed).toBe(true);
+ }
+ } finally { client?.close(); void server.stop(true); await result; }
+});
diff --git a/packages/core/test/model-switch.test.ts b/packages/core/test/model-switch.test.ts
index f26017c..61a9763 100644
--- a/packages/core/test/model-switch.test.ts
+++ b/packages/core/test/model-switch.test.ts
@@ -33,7 +33,9 @@ test('account switching keeps the thread, carries prior exchanges and never reus
});
const run = async (prompt: string) => {
const turn = await client.call('turns.start', { threadId, prompt });
- await waitFor(() => h.core.journal.getTurn(turn.id)?.status === 'done');
+ await waitFor(() => h.core.journal.getTurn(turn.id)?.finishedAt != null);
+ expect(h.core.journal.getTurn(turn.id)?.error).toBeNull();
+ expect(h.core.journal.getTurn(turn.id)?.status).toBe('done');
};
await run('the passwordless project uses blue widgets');
const switched = await client.call('threads.update', { threadId, accountId: 'second-account', model: 'echo' });
diff --git a/packages/ui/src/components/Composer.test.ts b/packages/ui/src/components/Composer.test.ts
index a85b528..bcfa405 100644
--- a/packages/ui/src/components/Composer.test.ts
+++ b/packages/ui/src/components/Composer.test.ts
@@ -313,6 +313,33 @@ test('a model with no reasoning scale gets no chip at all', async () => {
expect(query('[data-testid=composer-picker]').textContent).toContain('Claude Haiku 4.5');
});
+test('the picker keeps row, account and legacy keyboard navigation separate', async () => {
+ await mountOnFake();
+ await openDraft();
+ query('[data-testid=composer-picker]').click();
+ await waitFor(() => document.querySelector('[data-testid=composer-picker-menu]') !== null);
+
+ const claudeTile = query('[data-provider=claude]');
+ claudeTile.focus();
+ expect(press('ArrowDown')).toBe(false);
+ expect(document.activeElement).toBe(query('[data-provider=echo]'));
+
+ const firstSeat = query('[data-instance="claude::a-claude-main"]');
+ const secondSeat = query('[data-instance="claude::a-claude-side"]');
+ firstSeat.focus();
+ expect(press('ArrowRight')).toBe(false);
+ expect(document.activeElement).toBe(secondSeat);
+ expect(press('ArrowLeft')).toBe(false);
+ expect(document.activeElement).toBe(firstSeat);
+
+ const legacyRow = query('[data-testid=picker-legacy]');
+ legacyRow.focus();
+ expect(press('ArrowRight')).toBe(false);
+ await waitFor(() => document.activeElement?.closest('[data-testid=picker-legacy-menu]') !== null);
+ expect(press('ArrowLeft')).toBe(false);
+ expect(document.activeElement).toBe(legacyRow);
+});
+
test('a refused turn keeps the prompt for Enter and Ctrl+Enter', async () => {
await mountOnFake();
await store.open('t-trace');
diff --git a/packages/ui/src/components/ModelPicker.svelte b/packages/ui/src/components/ModelPicker.svelte
index 1aecc51..2b2cbf8 100644
--- a/packages/ui/src/components/ModelPicker.svelte
+++ b/packages/ui/src/components/ModelPicker.svelte
@@ -284,51 +284,70 @@
return root ? Array.from(root.querySelectorAll('.popover .models [data-row]:not(:disabled)')) : [];
}
- function onkeydown(event: KeyboardEvent) {
- if (!popover.open) return;
- const active = document.activeElement as HTMLElement | null;
- if (event.key === 'ArrowRight' && active?.dataset.testid === 'picker-legacy') { event.preventDefault(); legacy.show(); queueMicrotask(() => root?.querySelector('[data-testid=picker-legacy-menu] [data-model]')?.focus()); return; }
- if (event.key === 'ArrowLeft' && active?.closest('[data-testid=picker-legacy-menu]')) { event.preventDefault(); legacy.hide(); root?.querySelector('[data-testid=picker-legacy]')?.focus(); return; }
- const searching = searchable && (active === searchBox || (active?.hasAttribute('data-model') ?? false));
-
- if (event.key === 'Escape') {
- event.stopPropagation();
- if (legacyOpen) { legacy.hide(); root?.querySelector('[data-testid=picker-legacy]')?.focus(); return; }
- // The query goes first: closing on it would throw away what was just typed.
- if (searchable && modelQuery !== '') {
- modelQuery = '';
- searchBox?.focus();
- return;
- }
- popover.hide();
+ function handleEscape(event: KeyboardEvent) {
+ event.stopPropagation();
+ if (legacyOpen) {
+ legacy.hide();
+ root?.querySelector('[data-testid=picker-legacy]')?.focus();
return;
}
- if (event.key === 'Enter' && searching) {
- // A focused row is activated by the browser too; taking the default keeps it to one pick.
- event.preventDefault();
- const row = active === searchBox ? modelRows()[0] : active;
- row?.click();
+ // The query goes first: closing on it would throw away what was just typed.
+ if (searchable && modelQuery !== '') {
+ modelQuery = '';
+ searchBox?.focus();
return;
}
- if (searching && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
+ popover.hide();
+ }
+
+ function handleLegacyNavigation(event: KeyboardEvent, active: HTMLElement | null): boolean {
+ if (event.key === 'ArrowRight' && active?.dataset.testid === 'picker-legacy') {
event.preventDefault();
- const list = modelRows();
- const here = active === searchBox || !active ? -1 : list.indexOf(active);
- if (event.key === 'ArrowDown') list[Math.min(here + 1, list.length - 1)]?.focus();
- else if (here <= 0) searchBox?.focus();
- else list[here - 1]?.focus();
- return;
+ legacy.show();
+ queueMicrotask(() => root?.querySelector('[data-testid=picker-legacy-menu] [data-model]')?.focus());
+ return true;
}
- if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
- // The account chips are a segmented control: the arrows walk them.
- const chips = root ? Array.from(root.querySelectorAll('.popover [data-seat]:not(:disabled)')) : [];
- const here = chips.indexOf(document.activeElement as HTMLElement);
- if (here === -1) return;
+ if (event.key === 'ArrowLeft' && active?.closest('[data-testid=picker-legacy-menu]')) {
event.preventDefault();
- const step = event.key === 'ArrowRight' ? 1 : -1;
- chips[(here + step + chips.length) % chips.length]?.focus();
- return;
+ legacy.hide();
+ root?.querySelector('[data-testid=picker-legacy]')?.focus();
+ return true;
}
+ return false;
+ }
+
+ function handleModelSearch(event: KeyboardEvent, active: HTMLElement | null): boolean {
+ if (!searchable || (active !== searchBox && !active?.hasAttribute('data-model'))) return false;
+ if (event.key === 'Enter') {
+ // A focused row is activated by the browser too; taking the default keeps it to one pick.
+ event.preventDefault();
+ const row = active === searchBox ? modelRows()[0] : active;
+ row?.click();
+ return true;
+ }
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return false;
+ event.preventDefault();
+ const list = modelRows();
+ const here = active === searchBox || !active ? -1 : list.indexOf(active);
+ if (event.key === 'ArrowDown') list[Math.min(here + 1, list.length - 1)]?.focus();
+ else if (here <= 0) searchBox?.focus();
+ else list[here - 1]?.focus();
+ return true;
+ }
+
+ function handleAccountNavigation(event: KeyboardEvent, active: HTMLElement | null): boolean {
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return false;
+ // The account chips are a segmented control: the arrows walk them.
+ const chips = root ? Array.from(root.querySelectorAll('.popover [data-seat]:not(:disabled)')) : [];
+ const here = active ? chips.indexOf(active) : -1;
+ if (here === -1) return false;
+ event.preventDefault();
+ const step = event.key === 'ArrowRight' ? 1 : -1;
+ chips[(here + step + chips.length) % chips.length]?.focus();
+ return true;
+ }
+
+ function handleRowNavigation(event: KeyboardEvent, active: HTMLElement | null) {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
const list = focusable();
if (list.length === 0) return;
@@ -338,6 +357,16 @@
list[next]?.focus();
}
+ function onkeydown(event: KeyboardEvent) {
+ if (!popover.open) return;
+ const active = document.activeElement as HTMLElement | null;
+ if (event.key === 'Escape') return handleEscape(event);
+ if (handleLegacyNavigation(event, active)) return;
+ if (handleModelSearch(event, active)) return;
+ if (handleAccountNavigation(event, active)) return;
+ handleRowNavigation(event, active);
+ }
+
function onWindowPointerdown(event: PointerEvent) {
if (!popover.open) return;
if (root && event.target instanceof Node && root.contains(event.target)) return;
diff --git a/packages/ui/src/lib/markdown.test.ts b/packages/ui/src/lib/markdown.test.ts
index 2efdbd7..0eb04ad 100644
--- a/packages/ui/src/lib/markdown.test.ts
+++ b/packages/ui/src/lib/markdown.test.ts
@@ -2,6 +2,13 @@ import { describe, expect, test } from 'vitest';
import { renderMarkdown, withCaret } from './markdown';
describe('renderMarkdown', () => {
+ test('inline code keeps emphasis and links literal', () => {
+ expect(renderMarkdown('`**literal**` and **bold**')).toBe('**literal** and bold
');
+ expect(renderMarkdown('`[x](https://example.test)`')).toBe('[x](https://example.test)
');
+ expect(renderMarkdown('``a ` b`` and ``')).toBe('a ` b and <tag>
');
+ expect(renderMarkdown('**`code`** and `[x](https://example.test)`')).toBe('code and [x](https://example.test)
');
+ expect(renderMarkdown('\0 `**code**`')).toBe('\0 **code**
');
+ });
test('paragraphs, headings, inline marks and links, everything escaped', () => {
expect(renderMarkdown('one\ntwo\n\nthree')).toBe('one\ntwo
three
');
expect(renderMarkdown('# Title\n## Sub')).toBe('Title
Sub
');
diff --git a/packages/ui/src/lib/markdown.ts b/packages/ui/src/lib/markdown.ts
index cd0093d..e3b818a 100644
--- a/packages/ui/src/lib/markdown.ts
+++ b/packages/ui/src/lib/markdown.ts
@@ -15,8 +15,19 @@ function escape(text: string): string {
}
function inline(text: string): string {
+ const code: string[] = [];
+ let marker = '\0';
+ while (text.includes(marker)) marker += '\0';
+ const protectedText = text.replace(/(? {
+ code.push(`${escape(value)}`);
+ return `${marker}${code.length - 1}${marker}`;
+ });
+ // Format around opaque spans, then restore them without parsing their contents.
+ return inlineFormatting(protectedText).split(marker).map((part, index) => index % 2 ? code[Number(part)]! : part).join('');
+}
+
+function inlineFormatting(text: string): string {
let out = escape(text);
- out = out.replace(/`([^`\n]+)`/g, '$1');
out = out.replace(/\*\*([^*\n]+)\*\*/g, '$1');
out = out.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1$2');
out = out.replace(/~~([^~\n]+)~~/g, '$1');
@@ -89,9 +100,7 @@ export function renderMarkdown(source: string): string {
const lines = source.replaceAll('\r\n', '\n').split('\n');
const html: string[] = [];
let paragraph: string[] = [];
- let list: ListItem[] | null = null;
- /** The items open at each indent, innermost last: where the next line nests. */
- let stack: ListItem[] = [];
+ const list = new MarkdownList();
const flushParagraph = (): void => {
if (paragraph.length === 0) return;
@@ -99,10 +108,7 @@ export function renderMarkdown(source: string): string {
paragraph = [];
};
const flushList = (): void => {
- if (!list) return;
- html.push(renderItems(list));
- list = null;
- stack = [];
+ html.push(list.flush());
};
const flushAll = (): void => {
flushParagraph();
@@ -111,17 +117,11 @@ export function renderMarkdown(source: string): string {
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? '';
- const fence = FENCE.exec(line);
+ const fence = readFence(lines, index);
if (fence) {
flushAll();
- const code: string[] = [];
- index += 1;
- while (index < lines.length && !FENCE.test(lines[index] ?? '')) {
- code.push(lines[index] ?? '');
- index += 1;
- }
- const language = fence[1] ? ` data-language="${escape(fence[1])}"` : '';
- html.push(`${escape(code.join('\n'))}
`);
+ html.push(fence.html);
+ index = fence.end;
continue;
}
@@ -144,70 +144,32 @@ export function renderMarkdown(source: string): string {
continue;
}
- if (QUOTE.test(line)) {
+ const quote = readQuote(lines, index);
+ if (quote) {
flushAll();
- const quoted: string[] = [];
- while (index < lines.length) {
- const match = QUOTE.exec(lines[index] ?? '');
- if (!match) break;
- quoted.push(match[1] ?? '');
- index += 1;
- }
- index -= 1;
- html.push(`${renderMarkdown(quoted.join('\n'))}
`);
+ html.push(quote.html);
+ index = quote.end;
continue;
}
// A table is a row, a delimiter row, then rows until a blank or a line with no pipe.
- const next = lines[index + 1] ?? '';
- if (line.includes('|') && TABLE_DELIMITER.test(next) && cells(next).length >= 1 && !ITEM.test(line)) {
+ const table = readTable(lines, index);
+ if (table) {
flushAll();
- const body: string[] = [];
- let cursor = index + 2;
- while (cursor < lines.length) {
- const candidate = lines[cursor] ?? '';
- if (candidate.trim() === '' || !candidate.includes('|')) break;
- body.push(candidate);
- cursor += 1;
- }
- html.push(renderTable(line, next, body));
- index = cursor - 1;
+ html.push(table.html);
+ index = table.end;
continue;
}
- const item = ITEM.exec(line);
+ const item = parseListItem(line);
if (item) {
flushParagraph();
- const indent = (item[1] ?? '').length;
- const kind: 'ul' | 'ol' = BULLET_MARK.test(line) ? 'ul' : 'ol';
- const task = TASK.exec(item[2] ?? '');
- const entry: ListItem = {
- indent,
- kind,
- text: task ? (task[2] ?? '') : (item[2] ?? ''),
- task: task ? ((task[1] ?? ' ') === ' ' ? 'open' : 'done') : null,
- children: []
- };
- // Pop every open item at this depth or deeper: the new one is their sibling or an uncle.
- while (stack.length > 0 && (stack[stack.length - 1]?.indent ?? 0) >= indent) stack.pop();
- const parent = stack[stack.length - 1];
- if (!parent) {
- if (list && list[0]?.kind !== kind && list[0]?.indent === indent) flushList();
- if (!list) list = [];
- list.push(entry);
- } else {
- parent.children.push(entry);
- }
- stack.push(entry);
+ html.push(list.add(item));
continue;
}
// Indented text under an item continues that item.
- const open = stack[stack.length - 1];
- if (open && /^\s{2,}\S/.test(line)) {
- open.text += ` ${line.trim()}`;
- continue;
- }
+ if (list.continue(line)) continue;
flushList();
paragraph.push(line);
@@ -217,6 +179,93 @@ export function renderMarkdown(source: string): string {
return html.join('');
}
+interface Block { html: string; end: number }
+
+function readFence(lines: string[], start: number): Block | null {
+ const fence = FENCE.exec(lines[start] ?? '');
+ if (!fence) return null;
+ const code: string[] = [];
+ let end = start + 1;
+ while (end < lines.length && !FENCE.test(lines[end] ?? '')) code.push(lines[end++] ?? '');
+ const language = fence[1] ? ` data-language="${escape(fence[1])}"` : '';
+ return { html: `${escape(code.join('\n'))}
`, end };
+}
+
+function readQuote(lines: string[], start: number): Block | null {
+ const quoted: string[] = [];
+ let cursor = start;
+ while (cursor < lines.length) {
+ const match = QUOTE.exec(lines[cursor] ?? '');
+ if (!match) break;
+ quoted.push(match[1] ?? '');
+ cursor++;
+ }
+ return quoted.length ? { html: `${renderMarkdown(quoted.join('\n'))}
`, end: cursor - 1 } : null;
+}
+
+function readTable(lines: string[], start: number): Block | null {
+ const head = lines[start] ?? '';
+ const delimiter = lines[start + 1] ?? '';
+ if (!head.includes('|') || !TABLE_DELIMITER.test(delimiter) || ITEM.test(head)) return null;
+ const body: string[] = [];
+ let cursor = start + 2;
+ while (cursor < lines.length) {
+ const line = lines[cursor] ?? '';
+ if (!line.trim() || !line.includes('|')) break;
+ body.push(line);
+ cursor++;
+ }
+ return { html: renderTable(head, delimiter, body), end: cursor - 1 };
+}
+
+function parseListItem(line: string): ListItem | null {
+ const item = ITEM.exec(line);
+ if (!item) return null;
+ const text = item[2] ?? '';
+ const task = TASK.exec(text);
+ return {
+ indent: (item[1] ?? '').length,
+ kind: BULLET_MARK.test(line) ? 'ul' : 'ol',
+ text: task ? task[2] ?? '' : text,
+ task: task ? (task[1] === ' ' ? 'open' : 'done') : null,
+ children: []
+ };
+}
+
+/** Open ancestors retain the list nesting while blocks consume source lines. */
+class MarkdownList {
+ private items: ListItem[] = [];
+ private stack: ListItem[] = [];
+
+ add(entry: ListItem): string {
+ while (this.stack.length && this.stack[this.stack.length - 1]!.indent >= entry.indent) this.stack.pop();
+ const parent = this.stack[this.stack.length - 1];
+ let previous = '';
+ if (parent) parent.children.push(entry);
+ else {
+ const first = this.items[0];
+ if (first && first.kind !== entry.kind && first.indent === entry.indent) previous = this.flush();
+ this.items.push(entry);
+ }
+ this.stack.push(entry);
+ return previous;
+ }
+
+ continue(line: string): boolean {
+ const open = this.stack[this.stack.length - 1];
+ if (!open || !/^\s{2,}\S/.test(line)) return false;
+ open.text += ` ${line.trim()}`;
+ return true;
+ }
+
+ flush(): string {
+ const html = this.items.length ? renderItems(this.items) : '';
+ this.items = [];
+ this.stack = [];
+ return html;
+ }
+}
+
/** Where the caret goes when the text ends on a block that closes in several tags. */
const TAILS = ['', '', '', '', '
'];
diff --git a/packages/ui/src/lib/workspace.svelte.ts b/packages/ui/src/lib/workspace.svelte.ts
index 4b2f341..f9988f2 100644
--- a/packages/ui/src/lib/workspace.svelte.ts
+++ b/packages/ui/src/lib/workspace.svelte.ts
@@ -7,7 +7,8 @@ import {
upsertEnvironment,
readStoredEndpoint,
fromTauri,
- type Endpoint
+ type Endpoint,
+ type StoredEnvironment
} from './endpoint';
import { strings } from './strings';
@@ -29,6 +30,17 @@ function profiles(): Record {
try { return JSON.parse(localStorage.getItem(PROFILE_KEY) ?? '{}') ?? {}; } catch { return {}; }
}
+/** A connection's URL is also its identity in the workspace and saved list. */
+function endpointIdentity(endpoint: Endpoint): { id: string; host: string } | null {
+ try {
+ const url = new URL(endpoint.url);
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null;
+ return { id: url.toString().replace(/\/+$/, ''), host: url.host };
+ } catch {
+ return null;
+ }
+}
+
/** Each host keeps its own ids, credentials and pending requests. */
export class Workspace {
machines = $state([]);
@@ -38,6 +50,60 @@ export class Workspace {
#generation = 0;
#lifecycle = 0;
+ #current(lifecycle: number): boolean {
+ return lifecycle === this.#lifecycle;
+ }
+
+ #primaryMachine(selected: Endpoint | null, remembered: StoredEnvironment[]): Machine {
+ const machine: Machine = {
+ id: store.endpointUrl ?? 'local',
+ label: remembered.find(e => e.url === selected?.url)?.label ?? (store.localCore ? strings.machines.local : store.core?.hostname) ?? strings.machines.local,
+ store
+ };
+ this.restoreProfile(machine);
+ return machine;
+ }
+
+ async #addFakeMachine(lifecycle: number): Promise {
+ const { FakeClient } = await import('./fake-client');
+ if (!this.#current(lifecycle)) return;
+ const remote = new Store();
+ remote.machineId = 'http://builder.test';
+ remote.visible = false;
+ remote.attach(new FakeClient());
+ await remote.connect();
+ if (!this.#current(lifecycle)) {
+ remote.client?.close();
+ remote.detach();
+ return;
+ }
+ remote.booted = true;
+ if (remote.core) remote.core.os = 'linux';
+ const machine = { id: remote.machineId, label: 'Builder', store: remote };
+ this.restoreProfile(machine);
+ this.machines = [...this.machines, machine];
+ }
+
+ async #connectShellLocal(lifecycle: number): Promise {
+ if (!window.__TAURI_INTERNALS__ || store.localCore) return;
+ const local = await fromTauri();
+ if (!this.#current(lifecycle)) return;
+ if (local && local.url !== store.endpointUrl) await this.add(local, strings.machines.local);
+ }
+
+ async #adoptPopulatedJournal(lifecycle: number): Promise {
+ // A fresh Dev core may have no threads while an older core on this PC has a journal.
+ const populated = store.localCore && store.threads.length === 0 && store.core?.hostname
+ ? this.machines.find(m => m.store !== store && m.store.connection === 'ready' && m.store.threads.length > 0 && m.store.core?.hostname === store.core?.hostname)
+ : undefined;
+ if (!populated) return;
+ await store.switchEnvironment(populated.id);
+ if (!this.#current(lifecycle) || store.connection !== 'ready') return;
+ populated.store.client?.close();
+ populated.store.detach();
+ this.machines = [{ id: populated.id, label: populated.label, icon: populated.icon, store }, ...this.machines.filter(m => m.store !== store && m !== populated)];
+ }
+
async boot(): Promise {
const lifecycle = ++this.#lifecycle;
store.visible = true;
@@ -50,32 +116,18 @@ export class Workspace {
const selected = readStoredEndpoint();
const remembered = readEnvironments();
await store.boot();
- if (lifecycle !== this.#lifecycle) return;
+ if (!this.#current(lifecycle)) return;
this.active = store;
- this.machines = [
- { id: store.endpointUrl ?? 'local', label: remembered.find(e => e.url === selected?.url)?.label ?? (store.localCore ? strings.machines.local : store.core?.hostname) ?? strings.machines.local, store }
- ];
- this.restoreProfile(this.machines[0]!);
+ this.machines = [this.#primaryMachine(selected, remembered)];
if (import.meta.env.DEV && new URLSearchParams(window.location.search).get('fake') === '1') {
if (new URLSearchParams(window.location.search).get('machines') === '1') {
- const { FakeClient } = await import('./fake-client');
- const remote = new Store();
- remote.machineId = 'http://builder.test';
- remote.visible = false;
- remote.attach(new FakeClient());
- await remote.connect();
- remote.booted = true;
- if (remote.core) remote.core.os = 'linux';
- this.machines = [...this.machines, { id: remote.machineId, label: 'Builder', store: remote }];
- this.restoreProfile(this.machines[1]!);
+ await this.#addFakeMachine(lifecycle);
}
return;
}
const primaryEndpoint = readStoredEndpoint();
- if (window.__TAURI_INTERNALS__ && !store.localCore) {
- const local = await fromTauri();
- if (local && local.url !== store.endpointUrl) await this.add(local, strings.machines.local);
- }
+ await this.#connectShellLocal(lifecycle);
+ if (!this.#current(lifecycle)) return;
if (!store.localCore && primaryEndpoint?.url === store.endpointUrl && primaryEndpoint.token) {
upsertEnvironment({ ...primaryEndpoint, paired: primaryEndpoint.paired ?? false, label: this.machines[0]!.label });
}
@@ -84,19 +136,8 @@ export class Workspace {
.filter((e) => e.url !== store.endpointUrl)
.map((e) => this.add(e, e.label))
);
- // Older workspaces added a fresh Dev core beside the paired core on the same PC.
- // Keep that existing journal as the primary connection when the fresh core has no threads.
- const populated = store.localCore && store.threads.length === 0 && store.core?.hostname
- ? this.machines.find(m => m.store !== store && m.store.connection === 'ready' && m.store.threads.length > 0 && m.store.core?.hostname === store.core?.hostname)
- : undefined;
- if (populated && lifecycle === this.#lifecycle) {
- await store.switchEnvironment(populated.id);
- if (store.connection === 'ready') {
- populated.store.client?.close();
- populated.store.detach();
- this.machines = [{ id: populated.id, label: populated.label, icon: populated.icon, store }, ...this.machines.filter(m => m.store !== store && m !== populated)];
- }
- }
+ if (!this.#current(lifecycle)) return;
+ await this.#adoptPopulatedJournal(lifecycle);
}
restoreProfile(machine: Machine): void {
@@ -125,17 +166,45 @@ export class Workspace {
}
}
+ /** Two URLs can reach the same core; keep the already connected machine. */
+ #discardAlias(machine: Machine): boolean {
+ const target = machine.store;
+ const alias = target.core?.hostname && this.machines.find(m => m.store !== target && m.store.core?.hostname && profileKey(m) === profileKey(machine));
+ if (!alias) return false;
+ target.client?.close();
+ target.detach();
+ this.machines = this.machines.filter(m => m.store !== target);
+ removeEnvironment(machine.id);
+ this.error = null;
+ return true;
+ }
+
+ /** Keep a connected machine's display name and resumable session credentials. */
+ #rememberConnected(machine: Machine, endpoint: Endpoint, label: string | undefined, host: string): void {
+ machine.label = label?.trim() || machine.store.core?.hostname || host;
+ this.restoreProfile(machine);
+ // Grant credentials are persisted by WsClient's onSession, never the grant itself.
+ if (!endpoint.grant && !endpoint.local)
+ upsertEnvironment({ url: machine.id, token: endpoint.token, paired: endpoint.paired ?? false, label: machine.label });
+ else {
+ const saved = readEnvironments().find((e) => e.url === machine.id);
+ if (saved) upsertEnvironment({ ...saved, label: machine.label });
+ }
+ this.machines = [...this.machines];
+ if (machine.store === store) {
+ const saved = readEnvironments().find((e) => e.url === machine.id);
+ if (saved) storeEndpoint(saved);
+ }
+ }
+
async add(endpoint: Endpoint, label?: string): Promise {
- let url: URL;
- try {
- url = new URL(endpoint.url);
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
- throw new Error();
- } catch {
+ const lifecycle = this.#lifecycle;
+ const identity = endpointIdentity(endpoint);
+ if (!identity) {
this.error = strings.machines.invalidUrl;
return false;
}
- const id = url.toString().replace(/\/+$/, '');
+ const { id, host } = identity;
const existing = this.machines.find((m) => m.id === id);
if (existing && existing.store.connection !== 'closed') {
this.error = strings.machines.duplicate;
@@ -146,41 +215,22 @@ export class Workspace {
target.detach();
target.machineId = id;
target.visible = this.active === target;
- const machine: Machine = existing ?? { id, label: label?.trim() || url.host, store: target };
+ const machine: Machine = existing ?? { id, label: label?.trim() || host, store: target };
if (!existing) this.machines = [...this.machines, machine];
await target.connectEndpoint({ ...endpoint, url: id });
- if (!this.machines.some((m) => m.store === target)) {
- target.client?.close();
- target.detach();
+ if (!this.#current(lifecycle) || !this.machines.some((m) => m.store === target)) {
+ if (!this.machines.some((m) => m.store === target)) {
+ target.client?.close();
+ target.detach();
+ }
return false;
}
if (target.connection !== 'ready') {
this.error = `${machine.label}: ${target.error ?? strings.connection.closed}`;
return false;
}
- const alias = target.core?.hostname && this.machines.find(m => m.store !== target && m.store.core?.hostname && profileKey(m) === profileKey(machine));
- if (alias) {
- target.client?.close();
- target.detach();
- this.machines = this.machines.filter(m => m.store !== target);
- removeEnvironment(id);
- this.error = null;
- return true;
- }
- machine.label = label?.trim() || target.core?.hostname || url.host;
- this.restoreProfile(machine);
- // Grant credentials are persisted by WsClient's onSession, never the grant itself.
- if (!endpoint.grant && !endpoint.local)
- upsertEnvironment({ url: id, token: endpoint.token, paired: endpoint.paired ?? false, label: machine.label });
- else {
- const saved = readEnvironments().find((e) => e.url === id);
- if (saved) upsertEnvironment({ ...saved, label: machine.label });
- }
- this.machines = [...this.machines];
- if (target === store) {
- const saved = readEnvironments().find((e) => e.url === id);
- if (saved) storeEndpoint(saved);
- }
+ if (this.#discardAlias(machine)) return true;
+ this.#rememberConnected(machine, endpoint, label, host);
this.error = null;
return true;
}
diff --git a/packages/ui/src/lib/workspace.test.ts b/packages/ui/src/lib/workspace.test.ts
index d3ae92d..9629b97 100644
--- a/packages/ui/src/lib/workspace.test.ts
+++ b/packages/ui/src/lib/workspace.test.ts
@@ -25,6 +25,54 @@ test('restoring a remote selection also connects the shell local core', async ()
expect(add).toHaveBeenCalledWith({ url: 'http://127.0.0.1:41000', token: 'test', local: true }, 'My computer');
});
+test('closing while the shell endpoint loads does not add a machine afterward', async () => {
+ const { w, a } = await setup();
+ a.localCore = false;
+ a.endpointUrl = 'http://remote.test';
+ Object.defineProperty(window, '__TAURI_INTERNALS__', { value: {}, configurable: true });
+ vi.spyOn(a, 'boot').mockResolvedValue();
+ let resolveLocal!: (endpoint: endpoints.Endpoint) => void;
+ const local = new Promise((resolve) => { resolveLocal = resolve; });
+ const fromTauri = vi.spyOn(endpoints, 'fromTauri').mockReturnValue(local);
+ const add = vi.spyOn(w, 'add');
+ const boot = w.boot();
+ await waitFor(() => fromTauri.mock.calls.length === 1);
+ w.close();
+ resolveLocal({ url: 'http://127.0.0.1:41000', token: 'test', local: true });
+ await boot;
+ expect(add).not.toHaveBeenCalled();
+ expect(w.machines).toEqual([]);
+});
+
+test('an old endpoint connection cannot update a newer workspace lifecycle', async () => {
+ const { w } = await setup();
+ let release!: () => void;
+ const connected = new Promise((resolve) => { release = resolve; });
+ vi.spyOn(Store.prototype, 'connectEndpoint').mockImplementation(async function (this: Store) {
+ await connected;
+ this.connection = 'ready';
+ });
+ const endpoint = { url: 'http://late.test', token: 'test', paired: true };
+ const adding = w.add(endpoint, 'Old name');
+ await waitFor(() => w.machines.some((machine) => machine.id === endpoint.url));
+ const late = w.machines.find((machine) => machine.id === endpoint.url)!;
+ w.close();
+ const current = { ...late, label: 'New name' };
+ w.machines = [current];
+ release();
+ expect(await adding).toBe(false);
+ expect(w.machines[0]?.label).toBe('New name');
+ expect(endpoints.readEnvironments().some((saved) => saved.url === endpoint.url)).toBe(false);
+});
+
+async function waitFor(check: () => boolean): Promise {
+ for (let attempt = 0; attempt < 200; attempt++) {
+ if (check()) return;
+ await new Promise((resolve) => setTimeout(resolve, 2));
+ }
+ throw new Error('workspace did not reach the expected state');
+}
+
test('an empty local core yields to the remembered journal on the same computer', async () => {
const { w, a, b } = await setup();
a.localCore = true;
diff --git a/tests/e2e/ui.test.ts b/tests/e2e/ui.test.ts
index 36b7ba4..1afe0ca 100644
--- a/tests/e2e/ui.test.ts
+++ b/tests/e2e/ui.test.ts
@@ -758,6 +758,25 @@ test(
TIMEOUT,
);
+test('inline code stays literal at desktop and phone widths', async () => {
+ await page.type(testid('composer-input'), 'Keep `**literal**` and `[link](https://example.com)` as code.');
+ await clickWhenEnabled(testid('composer-send'));
+ const codes = `Array.from(document.querySelectorAll('[data-role=assistant] [data-testid=text-part] code'))`;
+ await page.waitFor(`${codes}.some(node => node.textContent === '**literal**')`);
+ await page.waitFor(`document.querySelector('[data-testid=thread-status]').dataset.status === 'idle'`);
+ expect(await page.evaluate(`${codes}.some(node => node.querySelector('strong, a'))`)).toBe(false);
+ expect(await page.evaluate(`${codes}.some(node => node.textContent === '[link](https://example.com)')`)).toBe(true);
+ try {
+ for (const [name, width, height] of [['desktop', 1280, 900], ['phone', 390, 844]] as const) {
+ await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: name === 'phone' });
+ await page.evaluate(`Promise.all([document.fonts.ready, ...document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {}))])`);
+ await page.screenshot(join(import.meta.dir, '.artifacts', `markdown-${name}.png`));
+ }
+ } finally {
+ await page.send('Emulation.clearDeviceMetricsOverride');
+ }
+}, TIMEOUT);
+
test(
'the service worker caches the shell, and the app still paints when the core is gone',
async () => {