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

@@ -192,7 +192,8 @@ export class OpenCodeServerBackend implements AgentBackend {
const st = this.byOpencodeId.get(p.sessionID); const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return; if (!st?.activeTurn) return;
this.bumpActivity(st); this.bumpActivity(st);
st.activeTurn.onEvent({ type: 'text', text: p.delta }); const cleaned = stripDcpTags(p.delta);
if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned });
return; return;
} }
case 'session.next.reasoning.delta': { case 'session.next.reasoning.delta': {
@@ -264,7 +265,8 @@ export class OpenCodeServerBackend implements AgentBackend {
st.activeTurn.onEvent({ type: 'reasoning', text: p.delta }); st.activeTurn.onEvent({ type: 'reasoning', text: p.delta });
} else if (p.field === 'text') { } else if (p.field === 'text') {
st.streamedPartKeys.add(`text:${p.partID}`); st.streamedPartKeys.add(`text:${p.partID}`);
st.activeTurn.onEvent({ type: 'text', text: p.delta }); const cleaned = stripDcpTags(p.delta);
if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned });
} }
return; return;
} }
@@ -301,7 +303,8 @@ export class OpenCodeServerBackend implements AgentBackend {
st.partTypeById.set(part.id, part.type); st.partTypeById.set(part.id, part.type);
const key = resolvePartDedupeKey(part, part.type); const key = resolvePartDedupeKey(part, part.type);
if (key && st.streamedPartKeys.delete(key)) return; // already streamed via delta if (key && st.streamedPartKeys.delete(key)) return; // already streamed via delta
const text = part.text ?? ''; const raw = part.text ?? '';
const text = part.type === 'text' ? stripDcpTags(raw) : raw;
if (text && part.time?.end != null) { if (text && part.time?.end != null) {
turn.onEvent({ type: part.type, text }); turn.onEvent({ type: part.type, text });
} }
@@ -717,6 +720,11 @@ function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms)); return new Promise((r) => setTimeout(r, ms));
} }
/** Strip opencode-dcp plugin tags that render as literal text in the UI. */
function stripDcpTags(s: string): string {
return s.replace(/<dcp-message-id>[^<]*<\/dcp-message-id>/g, '');
}
function errMsg(e: unknown): string { function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e); return e instanceof Error ? e.message : String(e);
} }

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Code, History, MessageSquare, Plus, Terminal, X } from 'lucide-react'; import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types'; import type { Chat, WorkspacePane } from '@/api/types';
import { StatusDot } from '@/components/StatusDot'; import { StatusDot } from '@/components/StatusDot';
import { import {
@@ -26,7 +26,9 @@ interface Props {
onCloseOthers: (chatId: string) => void; onCloseOthers: (chatId: string) => void;
onCloseToRight: (chatId: string) => void; onCloseToRight: (chatId: string) => void;
onCloseAll: () => void; onCloseAll: () => void;
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void; onNewTab: () => void;
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onReopenPane?: () => void;
onShowHistory: () => void; onShowHistory: () => void;
onRename: (chatId: string, name: string) => Promise<void>; onRename: (chatId: string, name: string) => Promise<void>;
onRemovePane?: () => void; onRemovePane?: () => void;
@@ -40,7 +42,9 @@ export function ChatTabBar({
onCloseOthers, onCloseOthers,
onCloseToRight, onCloseToRight,
onCloseAll, onCloseAll,
onAddPane, onNewTab,
onSplitPane,
onReopenPane,
onShowHistory, onShowHistory,
onRename, onRename,
onRemovePane, onRemovePane,
@@ -131,7 +135,7 @@ export function ChatTabBar({
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem onSelect={() => onAddPane('chat')}> <ContextMenuItem onSelect={onNewTab}>
New chat New chat
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
@@ -170,29 +174,49 @@ export function ChatTabBar({
)} )}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0"> <div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<button
type="button"
onClick={onNewTab}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New tab"
title="New tab"
>
<Plus size={12} />
</button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]" className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New pane" aria-label="Split pane"
title="New pane" title="Split pane"
> >
<Plus size={12} /> <Columns2 size={12} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit"> <DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}> <DropdownMenuItem onSelect={() => onSplitPane('chat')}>
<MessageSquare size={14} /> New BooChat <MessageSquare size={14} /> New BooChat
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}> <DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
<Terminal size={14} /> New BooTerm <Terminal size={14} /> New BooTerm
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('coder')}> <DropdownMenuItem onSelect={() => onSplitPane('coder')}>
<Code size={14} /> New BooCode <Code size={14} /> New BooCode
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{onReopenPane && (
<button
type="button"
onClick={onReopenPane}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Reopen closed pane"
title="Reopen closed pane"
>
<RotateCcw size={12} />
</button>
)}
<button <button
type="button" type="button"
onClick={onShowHistory} onClick={onShowHistory}

View File

@@ -65,6 +65,8 @@ export function Workspace({
showLandingPage, showLandingPage,
addSplitPane, addSplitPane,
removePane, removePane,
reopenPane,
hasClosedPanes,
isPaneChatPending, isPaneChatPending,
handlePaneDragStart, handlePaneDragStart,
handlePaneDragOver, handlePaneDragOver,
@@ -207,10 +209,9 @@ export function Workspace({
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)} onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)} onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)} onCloseAll={() => closeAllTabs(idx)}
onAddPane={(kind) => { onNewTab={() => void createChat(idx)}
if (kind === 'chat') void createChat(idx); onSplitPane={(kind) => onAddPane(kind)}
else onAddPane(kind); onReopenPane={hasClosedPanes ? reopenPane : undefined}
}}
onShowHistory={() => showLandingPage(idx)} onShowHistory={() => showLandingPage(idx)}
onRename={renameChat} onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined} onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}

View File

@@ -32,6 +32,21 @@ function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 }; 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 { function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
return kind === 'coder' ? 'BooCoder' : 'Terminal'; 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. // falls back to an empty pane to preserve the "always one pane" invariant.
toggleSettingsPane: () => string | null; toggleSettingsPane: () => string | null;
removePane: (idx: number) => void; removePane: (idx: number) => void;
reopenPane: () => void;
hasClosedPanes: boolean;
removeChatFromPanes: (chatId: string) => void; removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void; initializeFirstChatIfEmpty: (chatId: string) => void;
validatePanes: (validChatIds: Set<string>) => void; validatePanes: (validChatIds: Set<string>) => void;
@@ -394,6 +411,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
if (next.length > 1) { if (next.length > 1) {
// Last tab closed and other panes exist — remove the whole pane // Last tab closed and other panes exist — remove the whole pane
// instead of leaving an orphaned empty panel. // instead of leaving an orphaned empty panel.
pushClosed(pane); setHasClosedPanes(true);
const spliced = next.filter((_, i) => i !== paneIdx); const spliced = next.filter((_, i) => i !== paneIdx);
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1)); setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
return spliced; return spliced;
@@ -541,6 +559,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
// The endpoint is idempotent (404 on missing session) so a strict-mode // The endpoint is idempotent (404 on missing session) so a strict-mode
// double-invoke of the updater is safe. // double-invoke of the updater is safe.
const removed = prev[idx]; const removed = prev[idx];
if (removed) { pushClosed(removed); setHasClosedPanes(true); }
if (removed?.kind === 'terminal') { if (removed?.kind === 'terminal') {
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ }); api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
} }
@@ -550,6 +569,26 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
}); });
}, [sessionId]); }, [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 // 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. // chat fetch to land on the most-recent open chat if no saved pane state.
const initializeFirstChatIfEmpty = useCallback((chatId: string) => { const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
@@ -679,6 +718,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
addSplitPane, addSplitPane,
toggleSettingsPane, toggleSettingsPane,
removePane, removePane,
reopenPane,
hasClosedPanes,
removeChatFromPanes, removeChatFromPanes,
initializeFirstChatIfEmpty, initializeFirstChatIfEmpty,
validatePanes, validatePanes,