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:
2026-05-30 21:26:07 +00:00
parent 6d24726c3a
commit 315cdd23e2
4 changed files with 91 additions and 17 deletions

View File

@@ -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,