v2.2-paseo-providers: Paseo provider stack + v2.2.1 pane-scoped chat fixes

Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch
rewrite with streaming/persist, permission prompts, and agent commands.
Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline,
WS user-delta replace, and inference orphan tool_call stripping.
Archive openspec v2-2; update CHANGELOG and CURRENT.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 15:18:31 +00:00
parent 04673eaf59
commit 93d3f86c2b
96 changed files with 6694 additions and 1329 deletions

View File

@@ -0,0 +1,49 @@
import { useEffect, useSyncExternalStore } from 'react';
import { api } from '@/api/client';
import type { ProviderSnapshotEntry } from '@/api/types';
let cached: ProviderSnapshotEntry[] | null = null;
let inflight: Promise<ProviderSnapshotEntry[]> | null = null;
const listeners = new Set<() => void>();
function notify(): void {
for (const fn of listeners) fn();
}
function subscribe(fn: () => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
function getSnapshot(): ProviderSnapshotEntry[] | null {
return cached;
}
async function doFetch(cwd?: string): Promise<ProviderSnapshotEntry[]> {
const data = await api.coder.snapshot(cwd);
cached = data;
inflight = null;
notify();
return data;
}
function ensureLoaded(cwd?: string): void {
if (cached || inflight) return;
inflight = doFetch(cwd).catch((err) => {
inflight = null;
console.error('provider snapshot fetch failed:', err);
return [];
});
}
export function refreshProviderSnapshot(cwd?: string): Promise<ProviderSnapshotEntry[]> {
cached = null;
inflight = null;
return doFetch(cwd);
}
export function useProviderSnapshot(cwd?: string): ProviderSnapshotEntry[] | null {
const entries = useSyncExternalStore(subscribe, getSnapshot);
useEffect(() => { ensureLoaded(cwd); }, [cwd]);
return entries;
}

View File

@@ -48,9 +48,14 @@ function applyFrame(state: State, frame: WsFrame): State {
return { ...state, messages: [...state.messages, newMsg] };
}
case 'delta': {
const next = state.messages.map((m) =>
m.id === frame.message_id ? { ...m, content: m.content + frame.content } : m
);
const next = state.messages.map((m) => {
if (m.id !== frame.message_id) return m;
const chunk = frame.content ?? '';
if (m.role === 'user') {
return { ...m, content: chunk || m.content };
}
return { ...m, content: m.content + chunk };
});
return { ...state, messages: next };
}
case 'tool_call': {

View File

@@ -32,19 +32,19 @@ function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
// v1.10 booterm: terminal panes carry no chats. Their `id` is used as the
// tmux window key on booterm — see apps/booterm/src/pty/manager.ts. They
// persist in localStorage along with chat panes so a refresh resumes the
// same tmux window via the idempotent start endpoint.
function terminalPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'terminal', chatIds: [], activeChatIdx: -1 };
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
return kind === 'coder' ? 'BooCoder' : 'Terminal';
}
// v2.0.0: coder pane — renders the BooCoder interface (chat + diff panel).
// Like terminal panes, carries no chats — the CoderPane component manages
// its own session/messages via the /api/coder proxy.
function coderPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'coder', chatIds: [], activeChatIdx: -1 };
function scopedPane(id: string, kind: 'coder' | 'terminal', chatId: string): WorkspacePane {
return { id, kind, chatId, chatIds: [chatId], activeChatIdx: 0 };
}
/** Active chat id for a pane row (chat / coder / terminal). */
export function activePaneChatId(pane: WorkspacePane): string | undefined {
const idx = pane.activeChatIdx ?? 0;
if (idx >= 0 && pane.chatIds?.[idx]) return pane.chatIds[idx];
return pane.chatId;
}
// v1.9: settings pane factory. No chats, no state beyond identity — the
@@ -79,8 +79,20 @@ function htmlArtifactPane(state: HtmlArtifactState): WorkspacePane {
// v1.9: settings panes are ephemeral. Filter them out before persisting so a
// page reload always returns to a clean workspace; the user re-opens via the
// sidebar Settings button when needed.
function normalizePaneKind(pane: WorkspacePane): WorkspacePane {
// v2.3: server once accepted legacy 'agent' before 'coder' landed in the schema.
if ((pane.kind as string) === 'agent') {
return { ...pane, kind: 'coder' };
}
return pane;
}
function normalizePanes(panes: WorkspacePane[]): WorkspacePane[] {
return panes.map(normalizePaneKind);
}
function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
return panes.filter((p) => p.kind !== 'settings');
return normalizePanes(panes).filter((p) => p.kind !== 'settings');
}
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
@@ -128,6 +140,8 @@ export interface UseWorkspacePanesResult {
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
validatePanes: (validChatIds: Set<string>) => void;
/** True while a coder/terminal pane is waiting for its scoped chat row. */
isPaneChatPending: (paneId: string) => boolean;
handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
handlePaneDragLeave: () => void;
@@ -149,6 +163,54 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
// Tracks the last value broadcast by another device (or this one's own
// round-trip). If a PATCH would echo this exact payload, we skip the call.
const lastRemoteJsonRef = useRef<string>('[]');
const pendingPaneChatRef = useRef<Set<string>>(new Set());
const [pendingPaneChatIds, setPendingPaneChatIds] = useState<Set<string>>(() => new Set());
const markPaneChatPending = useCallback((paneId: string, pending: boolean) => {
setPendingPaneChatIds((prev) => {
const next = new Set(prev);
if (pending) next.add(paneId);
else next.delete(paneId);
pendingPaneChatRef.current = next;
return next;
});
}, []);
const attachChatToPane = useCallback(
(paneId: string, chatId: string, kind: 'coder' | 'terminal') => {
setPanes((prev) =>
prev.map((p) => (p.id === paneId ? scopedPane(paneId, kind, chatId) : p)),
);
},
[],
);
const seedPaneChat = useCallback(
async (paneId: string, kind: 'coder' | 'terminal') => {
if (pendingPaneChatRef.current.has(paneId)) return;
markPaneChatPending(paneId, true);
try {
const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind(kind) });
attachChatToPane(paneId, chat.id, kind);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create pane chat');
} finally {
markPaneChatPending(paneId, false);
}
},
[sessionId, attachChatToPane, markPaneChatPending],
);
const seedEmptyScopedPanes = useCallback(
(paneList: WorkspacePane[]) => {
for (const pane of paneList) {
if (pane.kind !== 'coder' && pane.kind !== 'terminal') continue;
if ((pane.chatIds?.length ?? 0) > 0 || pane.chatId) continue;
void seedPaneChat(pane.id, pane.kind);
}
},
[seedPaneChat],
);
// v1.12.1: hydrate from server on mount, then subscribe to remote updates.
useEffect(() => {
@@ -159,7 +221,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const session = await api.sessions.get(sessionId);
if (cancelled) return;
let initial: WorkspacePane[] = Array.isArray(session.workspace_panes)
? session.workspace_panes
? normalizePanes(session.workspace_panes)
: [];
// One-time migration: if server is empty but legacy localStorage has
// a layout, seed the server and delete the local key.
@@ -180,12 +242,13 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next));
setPanes(next);
setActivePaneIdx(0);
seedEmptyScopedPanes(next);
} finally {
if (!cancelled) hydratedRef.current = true;
}
})();
return () => { cancelled = true; };
}, [sessionId]);
}, [sessionId, seedEmptyScopedPanes]);
// v1.12.1: live cross-device sync. Replace local state when another device
// (or our own write echo) lands a session_workspace_updated frame.
@@ -193,14 +256,17 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return sessionEvents.subscribe((ev) => {
if (ev.type !== 'session_workspace_updated') return;
if (ev.session_id !== sessionId) return;
const incoming = Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [];
const incoming = normalizePanes(
Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [],
);
const json = JSON.stringify(incoming);
if (json === lastRemoteJsonRef.current) return;
lastRemoteJsonRef.current = json;
setPanes(incoming.length > 0 ? incoming : [emptyPane()]);
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
seedEmptyScopedPanes(incoming.length > 0 ? incoming : [emptyPane()]);
});
}, [sessionId]);
}, [sessionId, seedEmptyScopedPanes]);
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" emits one of
// these per click. If a pane already exists for the same message_id, focus
@@ -388,8 +454,10 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const showLandingPage = useCallback((paneIdx: number) => {
setPanes((prev) => {
const pane = prev[paneIdx];
// Coder/terminal panes are not chat hosts — history button is chat-only.
if (!pane || pane.kind === 'coder' || pane.kind === 'terminal') return prev;
const next = [...prev];
const pane = next[paneIdx]!;
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined };
return next;
});
@@ -408,16 +476,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return prev;
}
const newPane =
kind === 'terminal' ? terminalPane(newPaneId) :
kind === 'coder' ? coderPane(newPaneId) :
emptyPane(newPaneId);
kind === 'terminal'
? { id: newPaneId, kind: 'terminal' as const, chatIds: [] as string[], activeChatIdx: -1 }
: kind === 'coder'
? { id: newPaneId, kind: 'coder' as const, chatIds: [] as string[], activeChatIdx: -1 }
: emptyPane(newPaneId);
const next = [...prev, newPane];
setActivePaneIdx(next.length - 1);
success = true;
if (kind === 'terminal' || kind === 'coder') {
queueMicrotask(() => void seedPaneChat(newPaneId, kind));
}
return next;
});
return success ? newPaneId : null;
}, []);
}, [seedPaneChat]);
const toggleSettingsPane = useCallback(() => {
setPanes((prev) => {
@@ -476,19 +549,39 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const validatePanes = useCallback((validChatIds: Set<string>) => {
setPanes((prev) => {
const cleaned = prev.map((pane) => {
if (pane.kind !== 'chat' || pane.chatIds.length === 0) return pane;
const usesChat =
pane.kind === 'chat' || pane.kind === 'coder' || pane.kind === 'terminal';
if (!usesChat || pane.chatIds.length === 0) return pane;
const nextIds = pane.chatIds.filter((id) => validChatIds.has(id));
if (nextIds.length === pane.chatIds.length) return pane;
if (nextIds.length === 0) {
return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
if (pane.kind === 'chat') {
return { ...pane, kind: 'empty' as const, chatId: undefined, chatIds: [], activeChatIdx: -1 };
}
return { ...pane, chatId: undefined, chatIds: [], activeChatIdx: -1 };
}
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
return { ...pane, chatIds: nextIds, activeChatIdx: nextActiveIdx, chatId: nextIds[nextActiveIdx] };
});
const unchanged = cleaned.every((p, i) => p === prev[i]);
return unchanged ? prev : cleaned;
const next = unchanged ? prev : cleaned;
if (!unchanged) {
for (const pane of next) {
if (pane.kind === 'coder' && !activePaneChatId(pane)) {
queueMicrotask(() => void seedPaneChat(pane.id, 'coder'));
} else if (pane.kind === 'terminal' && !activePaneChatId(pane)) {
queueMicrotask(() => void seedPaneChat(pane.id, 'terminal'));
}
}
}
return next;
});
}, []);
}, [seedPaneChat]);
const isPaneChatPending = useCallback(
(paneId: string) => pendingPaneChatIds.has(paneId),
[pendingPaneChatIds],
);
const removeChatFromPanes = useCallback((chatId: string) => {
setPanes((prev) => prev.map((p) => {
@@ -574,6 +667,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
removeChatFromPanes,
initializeFirstChatIfEmpty,
validatePanes,
isPaneChatPending,
handlePaneDragStart,
handlePaneDragOver,
handlePaneDragLeave,