In-flight workspace UX work. - Extract a shared PaneHeaderActions cluster (+/Split/Reopen/History/Close) used by ChatTabBar + the Workspace coder/terminal pane headers, replacing the divergent per-header copies; SessionLandingPage history + useWorkspacePanes tweaks. - Fix coder-side correctness bug: resolveChatId read sessions.workspace_panes as a bare WorkspacePane[] but v2.6.5 widened it to a WorkspaceState envelope, so it mis-read panes and clobbered tabNumbers/nextTabNumber/closedPaneStack on every pane-chat write. New normalizeWorkspaceState handles either shape and preserves the envelope (+ regression test). - CLAUDE.md doc-sync (coder vitest suite, deploy-by-surface, dual-remote push, in-flight-web-WIP staging, release-branch naming). Web tsc + coder build + coder tests green. Builds on v2.7.6. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
4.0 KiB
TypeScript
111 lines
4.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { resolveChatId } from '../chat-resolve.js';
|
|
import type { Sql } from '../../db.js';
|
|
|
|
// Mock the porsager/postgres surface that chat-resolve.ts uses: a tagged-template
|
|
// `tx` (dispatched by query substring), `tx.json`, and `sql.begin(fn)` which just
|
|
// runs fn(tx). Captures the value written back to workspace_panes so we can assert
|
|
// the WorkspaceState envelope survives the UPDATE.
|
|
interface MockState {
|
|
stored: unknown; // initial sessions.workspace_panes value
|
|
existingChatOpen: boolean; // whether `SELECT id FROM chats ...` finds the active chat
|
|
newChatId: string;
|
|
written?: unknown; // captured tx.json(...) payload from `UPDATE sessions`
|
|
inserted: boolean; // whether INSERT INTO chats ran
|
|
}
|
|
|
|
interface MockTx {
|
|
(strings: TemplateStringsArray): Promise<unknown>;
|
|
json: (v: unknown) => unknown;
|
|
}
|
|
|
|
function mockSql(state: MockState): Sql {
|
|
const tx = ((strings: TemplateStringsArray) => {
|
|
const q = strings.join('');
|
|
if (q.includes('SELECT workspace_panes FROM sessions')) {
|
|
return Promise.resolve([{ workspace_panes: state.stored }]);
|
|
}
|
|
if (q.includes('FROM chats')) {
|
|
return Promise.resolve(state.existingChatOpen ? [{ id: 'placeholder' }] : []);
|
|
}
|
|
if (q.includes('INSERT INTO chats')) {
|
|
state.inserted = true;
|
|
return Promise.resolve([{ id: state.newChatId }]);
|
|
}
|
|
if (q.includes('UPDATE sessions')) {
|
|
return Promise.resolve([]);
|
|
}
|
|
return Promise.resolve([]);
|
|
}) as unknown as MockTx;
|
|
tx.json = (v: unknown) => {
|
|
state.written = v;
|
|
return v;
|
|
};
|
|
const sql = {
|
|
begin: (fn: (t: Sql) => Promise<unknown>) => fn(tx as unknown as Sql),
|
|
};
|
|
return sql as unknown as Sql;
|
|
}
|
|
|
|
const ENVELOPE = () => ({
|
|
panes: [{ id: 'pane-1', kind: 'coder', chatIds: [] as string[], activeChatIdx: 0 }],
|
|
tabNumbers: { 'chat-x': 3 },
|
|
nextTabNumber: 7,
|
|
closedPaneStack: [{ kind: 'coder', chatIds: ['old'], activeChatIdx: 0 }],
|
|
});
|
|
|
|
describe('resolveChatId — v2.6.5 WorkspaceState envelope', () => {
|
|
it('reads panes from the envelope without crashing (regression: panes.findIndex is not a function)', async () => {
|
|
const state: MockState = {
|
|
stored: ENVELOPE(),
|
|
existingChatOpen: false,
|
|
newChatId: 'new-chat-1',
|
|
inserted: false,
|
|
};
|
|
const chatId = await resolveChatId(mockSql(state), 'session-1', 'pane-1');
|
|
expect(chatId).toBe('new-chat-1');
|
|
expect(state.inserted).toBe(true);
|
|
});
|
|
|
|
it('preserves the envelope (tabNumbers/nextTabNumber/closedPaneStack) on write-back', async () => {
|
|
const state: MockState = {
|
|
stored: ENVELOPE(),
|
|
existingChatOpen: false,
|
|
newChatId: 'new-chat-1',
|
|
inserted: false,
|
|
};
|
|
await resolveChatId(mockSql(state), 'session-1', 'pane-1');
|
|
const w = state.written as Record<string, unknown>;
|
|
expect(Array.isArray(w.panes)).toBe(true); // envelope, not a bare array
|
|
expect(w.tabNumbers).toEqual({ 'chat-x': 3 });
|
|
expect(w.nextTabNumber).toBe(7);
|
|
expect(w.closedPaneStack).toEqual([{ kind: 'coder', chatIds: ['old'], activeChatIdx: 0 }]);
|
|
});
|
|
|
|
it('returns the existing open chat when the pane already has one', async () => {
|
|
const env = ENVELOPE();
|
|
env.panes[0]!.chatIds = ['existing-1'];
|
|
const state: MockState = {
|
|
stored: env,
|
|
existingChatOpen: true,
|
|
newChatId: 'should-not-be-used',
|
|
inserted: false,
|
|
};
|
|
const chatId = await resolveChatId(mockSql(state), 'session-1', 'pane-1');
|
|
expect(chatId).toBe('existing-1');
|
|
expect(state.inserted).toBe(false);
|
|
});
|
|
|
|
it('still accepts a legacy bare WorkspacePane[] array', async () => {
|
|
const state: MockState = {
|
|
stored: [{ id: 'pane-1', kind: 'coder', chatId: 'legacy-1', chatIds: ['legacy-1'], activeChatIdx: 0 }],
|
|
existingChatOpen: true,
|
|
newChatId: 'should-not-be-used',
|
|
inserted: false,
|
|
};
|
|
const chatId = await resolveChatId(mockSql(state), 'session-1', 'pane-1');
|
|
expect(chatId).toBe('legacy-1');
|
|
expect(state.inserted).toBe(false);
|
|
});
|
|
});
|