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:
49
apps/web/src/hooks/useProviderSnapshot.ts
Normal file
49
apps/web/src/hooks/useProviderSnapshot.ts
Normal 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;
|
||||
}
|
||||
@@ -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': {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user