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>
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
import type { Sql } from '../db.js';
|
|
|
|
interface WorkspacePaneRow {
|
|
id: string;
|
|
kind: string;
|
|
chatId?: string;
|
|
chatIds?: string[];
|
|
activeChatIdx?: number;
|
|
}
|
|
|
|
// v2.6.5: sessions.workspace_panes widened from a bare WorkspacePane[] to a
|
|
// WorkspaceState envelope { panes, tabNumbers, nextTabNumber, closedPaneStack }.
|
|
// (See the union validator in apps/server routes/sessions.ts + normalizeWorkspaceState
|
|
// in apps/server read_tab_by_number.ts — this is the coder-side mirror.)
|
|
interface WorkspaceStateRow {
|
|
panes: WorkspacePaneRow[];
|
|
tabNumbers: Record<string, number>;
|
|
nextTabNumber: number;
|
|
closedPaneStack: unknown[];
|
|
}
|
|
|
|
// MIGRATION: the stored value may be the legacy bare array OR the envelope.
|
|
// Normalize to a full envelope so callers always read `.panes` as an array and
|
|
// write the envelope back intact (preserving tabNumbers/nextTabNumber/closedPaneStack).
|
|
export function normalizeWorkspaceState(v: unknown): WorkspaceStateRow {
|
|
if (Array.isArray(v)) {
|
|
return { panes: v as WorkspacePaneRow[], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
|
|
}
|
|
if (v && typeof v === 'object' && Array.isArray((v as { panes?: unknown }).panes)) {
|
|
const env = v as Partial<WorkspaceStateRow>;
|
|
return {
|
|
panes: env.panes ?? [],
|
|
tabNumbers: env.tabNumbers ?? {},
|
|
nextTabNumber: env.nextTabNumber ?? 1,
|
|
closedPaneStack: env.closedPaneStack ?? [],
|
|
};
|
|
}
|
|
return { panes: [], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
|
|
}
|
|
|
|
function chatNameForKind(kind: string): string {
|
|
if (kind === 'coder' || kind === 'agent') return 'BooCoder';
|
|
if (kind === 'terminal') return 'Terminal';
|
|
return 'Chat';
|
|
}
|
|
|
|
function activeChatIdForPane(pane: WorkspacePaneRow): string | undefined {
|
|
const chatIds = pane.chatIds ?? [];
|
|
const idx = pane.activeChatIdx ?? 0;
|
|
if (idx >= 0 && idx < chatIds.length) return chatIds[idx];
|
|
return pane.chatId;
|
|
}
|
|
|
|
/** Resolve the active chat for a workspace pane; auto-seed when empty. */
|
|
export async function resolveChatId(
|
|
sql: Sql,
|
|
sessionId: string,
|
|
paneId: string,
|
|
): Promise<string | null> {
|
|
return sql.begin(async (tx) => {
|
|
const sessionRows = await tx<{ workspace_panes: unknown }[]>`
|
|
SELECT workspace_panes FROM sessions WHERE id = ${sessionId} FOR UPDATE
|
|
`;
|
|
if (sessionRows.length === 0) return null;
|
|
|
|
const state = normalizeWorkspaceState(sessionRows[0]!.workspace_panes);
|
|
const panes = state.panes;
|
|
const paneIdx = panes.findIndex((p) => p.id === paneId);
|
|
if (paneIdx < 0) return null;
|
|
|
|
const pane = panes[paneIdx]!;
|
|
const existingChatId = activeChatIdForPane(pane);
|
|
if (existingChatId) {
|
|
const chatRows = await tx<{ id: string }[]>`
|
|
SELECT id FROM chats
|
|
WHERE id = ${existingChatId}
|
|
AND session_id = ${sessionId}
|
|
AND status = 'open'
|
|
`;
|
|
if (chatRows.length > 0) return existingChatId;
|
|
}
|
|
|
|
const [newChat] = await tx<{ id: string }[]>`
|
|
INSERT INTO chats (session_id, name, status)
|
|
VALUES (${sessionId}, ${chatNameForKind(pane.kind)}, 'open')
|
|
RETURNING id
|
|
`;
|
|
if (!newChat) return null;
|
|
|
|
const nextChatIds = [...(pane.chatIds ?? []), newChat.id];
|
|
const nextActiveIdx = nextChatIds.length - 1;
|
|
const nextPanes = panes.map((p, i) =>
|
|
i === paneIdx
|
|
? {
|
|
...p,
|
|
chatIds: nextChatIds,
|
|
activeChatIdx: nextActiveIdx,
|
|
chatId: newChat.id,
|
|
}
|
|
: p,
|
|
);
|
|
|
|
const nextState: WorkspaceStateRow = { ...state, panes: nextPanes };
|
|
await tx`
|
|
UPDATE sessions
|
|
SET workspace_panes = ${tx.json(nextState as never)},
|
|
updated_at = clock_timestamp()
|
|
WHERE id = ${sessionId}
|
|
`;
|
|
|
|
return newChat.id;
|
|
});
|
|
}
|