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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user