feat: strip dcp-message-id tags from opencode output + reopen closed panes
Two independent fixes: - opencode-server.ts: stripDcpTags() removes <dcp-message-id>…</dcp-message-id> tags from text deltas before they reach the frame/DB. Applied to all three text paths (session.next.text.delta, message.part.delta text field, handleUpdatedPart text type). Reasoning/tool paths untouched. - useWorkspacePanes.ts: module-level closedPaneStack (capped at 10) captures pane kind + chatIds on removePane and removeTab auto-remove. reopenPane() pops the stack and re-attaches a new pane to the existing chat ids (chats survive pane close server-side). hasClosedPanes drives conditional render. - ChatTabBar.tsx: [+] is now instant new-tab (no dropdown); split-pane dropdown (Columns2 icon) opens Chat/Term/Code in a new pane; reopen button (RotateCcw icon) appears when closed panes exist. - Workspace.tsx: pass reopenPane + hasClosedPanes through to ChatTabBar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,21 @@ function chatPane(chatId: string): WorkspacePane {
|
||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||
}
|
||||
|
||||
interface ClosedPaneEntry {
|
||||
kind: WorkspacePane['kind'];
|
||||
chatIds: string[];
|
||||
activeChatIdx: number;
|
||||
}
|
||||
const MAX_CLOSED = 10;
|
||||
const closedPaneStack: ClosedPaneEntry[] = [];
|
||||
|
||||
function pushClosed(pane: WorkspacePane): void {
|
||||
if (pane.kind === 'empty' || pane.kind === 'settings') return;
|
||||
if (pane.chatIds.length === 0) return;
|
||||
closedPaneStack.push({ kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx });
|
||||
if (closedPaneStack.length > MAX_CLOSED) closedPaneStack.shift();
|
||||
}
|
||||
|
||||
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
|
||||
return kind === 'coder' ? 'BooCoder' : 'Terminal';
|
||||
}
|
||||
@@ -137,6 +152,8 @@ export interface UseWorkspacePanesResult {
|
||||
// falls back to an empty pane to preserve the "always one pane" invariant.
|
||||
toggleSettingsPane: () => string | null;
|
||||
removePane: (idx: number) => void;
|
||||
reopenPane: () => void;
|
||||
hasClosedPanes: boolean;
|
||||
removeChatFromPanes: (chatId: string) => void;
|
||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||
validatePanes: (validChatIds: Set<string>) => void;
|
||||
@@ -394,6 +411,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
if (next.length > 1) {
|
||||
// Last tab closed and other panes exist — remove the whole pane
|
||||
// instead of leaving an orphaned empty panel.
|
||||
pushClosed(pane); setHasClosedPanes(true);
|
||||
const spliced = next.filter((_, i) => i !== paneIdx);
|
||||
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
|
||||
return spliced;
|
||||
@@ -541,6 +559,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
// The endpoint is idempotent (404 on missing session) so a strict-mode
|
||||
// double-invoke of the updater is safe.
|
||||
const removed = prev[idx];
|
||||
if (removed) { pushClosed(removed); setHasClosedPanes(true); }
|
||||
if (removed?.kind === 'terminal') {
|
||||
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
|
||||
}
|
||||
@@ -550,6 +569,26 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
const [hasClosedPanes, setHasClosedPanes] = useState(closedPaneStack.length > 0);
|
||||
|
||||
const reopenPane = useCallback(() => {
|
||||
const entry = closedPaneStack.pop();
|
||||
setHasClosedPanes(closedPaneStack.length > 0);
|
||||
if (!entry) return;
|
||||
setPanes((prev) => {
|
||||
const restored: WorkspacePane = {
|
||||
id: generateId(),
|
||||
kind: entry.kind,
|
||||
chatId: entry.chatIds[entry.activeChatIdx] ?? entry.chatIds[0],
|
||||
chatIds: entry.chatIds,
|
||||
activeChatIdx: Math.min(entry.activeChatIdx, entry.chatIds.length - 1),
|
||||
};
|
||||
const next = [...prev, restored];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Replaces a single empty default pane with a chat pane. Used by the initial
|
||||
// chat fetch to land on the most-recent open chat if no saved pane state.
|
||||
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
|
||||
@@ -679,6 +718,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
addSplitPane,
|
||||
toggleSettingsPane,
|
||||
removePane,
|
||||
reopenPane,
|
||||
hasClosedPanes,
|
||||
removeChatFromPanes,
|
||||
initializeFirstChatIfEmpty,
|
||||
validatePanes,
|
||||
|
||||
Reference in New Issue
Block a user