feat(web): workspace panes & tabs overhaul
A cohesive batch of pane/tab UX + the persisted workspace-state model (grouped
because the changes interleave across useWorkspacePanes, ChatTabBar, Workspace,
sessionEvents and the api types/client):
- Open a whole chat in a fresh pane via a new open_chat_in_new_pane event:
ChatTabBar tab context menu "Open in new pane", and MessageBubble.fork() now
lands the fork beside the original instead of replacing the active pane.
openChatInNewPane detaches the chat from any pane already holding it
(one-chat-per-pane).
- The tab-bar "+" becomes a New BooChat/BooTerm/BooCode menu (chat as a tab,
term/coder as split panes); the split button is unchanged.
- Drop the per-message "Open in pane" button (it opened a single message's
artifact) and its dead code; the artifact-pane machinery is left orphaned for
a later teardown.
- Session history: the empty/landing pane lists the session's open chats plus
archived chats (fetched separately), click to open / restore-and-open.
- Relocate-on-close: closing a chat pane moves its tabs (in order) into the
oldest chat/empty pane instead of discarding them; terminal/coder panes close
as before. Reopen strips the restored chatIds from all live panes first, so a
relocated-then-reopened pane never duplicates a tab — no stack-shape change.
- Stable global tab numbering: tabNumbers/nextTabNumber assigned on chat-pane
open, retired on close (never reused), rendered map-keyed (not positional).
- workspace_panes is now a WorkspaceState envelope { panes, tabNumbers,
nextTabNumber, closedPaneStack }; the reopen stack moved from a module-level
array into the persisted envelope so it survives reload. Hydrate/persist
normalize the legacy bare-array shape. appendClosed dedupes a value-identical
top entry to neutralize the StrictMode double-invoke of the setPanes updater.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,11 +16,15 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
pane: WorkspacePane;
|
||||
tabs: Chat[];
|
||||
// v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by
|
||||
// chat.id, NEVER by tab position.
|
||||
tabNumbers: Record<string, number>;
|
||||
onSwitchTab: (tabIdx: number) => void;
|
||||
onRemoveTab: (chatId: string) => void;
|
||||
onCloseOthers: (chatId: string) => void;
|
||||
@@ -37,6 +41,7 @@ interface Props {
|
||||
export function ChatTabBar({
|
||||
pane,
|
||||
tabs,
|
||||
tabNumbers,
|
||||
onSwitchTab,
|
||||
onRemoveTab,
|
||||
onCloseOthers,
|
||||
@@ -83,6 +88,9 @@ export function ChatTabBar({
|
||||
const isLast = tabIdx === tabs.length - 1;
|
||||
const onlyTab = tabs.length === 1;
|
||||
const label = chat.name ?? 'New chat';
|
||||
// v2.6.x: stable tab number keyed by chat.id (NOT tab position).
|
||||
// Omit gracefully when not yet assigned.
|
||||
const tabNumber = tabNumbers[chat.id];
|
||||
return (
|
||||
<ContextMenu key={chat.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
@@ -117,8 +125,11 @@ export function ChatTabBar({
|
||||
className="bg-transparent border-b border-border text-xs outline-none w-28"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[140px]" title={label}>
|
||||
{label}
|
||||
<span
|
||||
className="truncate max-w-[140px]"
|
||||
title={tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||
>
|
||||
{tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -138,6 +149,13 @@ export function ChatTabBar({
|
||||
<ContextMenuItem onSelect={onNewTab}>
|
||||
New chat
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id })
|
||||
}
|
||||
>
|
||||
Open in new pane
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
||||
Rename
|
||||
@@ -174,15 +192,31 @@ export function ChatTabBar({
|
||||
)}
|
||||
|
||||
<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>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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]"
|
||||
aria-label="New chat, terminal, or coder"
|
||||
title="New chat / terminal / coder"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit">
|
||||
{/* New BooChat opens a tab in THIS pane; terminal/coder can't be
|
||||
tabs, so they split into a new pane (matches the Split menu). */}
|
||||
<DropdownMenuItem onSelect={onNewTab}>
|
||||
<MessageSquare size={14} /> New BooChat
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
|
||||
<Terminal size={14} /> New BooTerm
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
|
||||
<Code size={14} /> New BooCode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen, Brain } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||
import { api, ApiError } from '@/api/client';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||
import { CapHitSentinel } from './CapHitSentinel';
|
||||
@@ -105,18 +105,6 @@ const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
|
||||
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
|
||||
// panes can render assistant content with the same Shiki + remark-gfm setup.
|
||||
|
||||
// Pane-header title derivation for a markdown artifact. Order matches the
|
||||
// server slug logic in services/artifacts.ts: first `# ` heading → first 6
|
||||
// words of the body → 'Markdown artifact'. Truncated to keep the pane header
|
||||
// readable.
|
||||
function deriveMarkdownTitle(content: string): string {
|
||||
const headingMatch = content.match(/^\s*#\s+(.+?)\s*$/m);
|
||||
if (headingMatch && headingMatch[1]) return headingMatch[1].slice(0, 80);
|
||||
const words = content.trim().split(/\s+/).slice(0, 6).join(' ');
|
||||
if (words) return words.slice(0, 80);
|
||||
return 'Markdown artifact';
|
||||
}
|
||||
|
||||
export interface MessageActions {
|
||||
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
|
||||
onResend?: (chatId: string, content: string) => Promise<void>;
|
||||
@@ -129,8 +117,8 @@ interface Props {
|
||||
sessionChats?: Chat[];
|
||||
capHitInfo?: { position: number; isLatest: boolean };
|
||||
actions?: MessageActions;
|
||||
/** Hide actions that don't apply (fork, delete, open-in-pane). */
|
||||
hideActions?: ('fork' | 'delete' | 'openInPane')[];
|
||||
/** Hide actions that don't apply (fork, delete). */
|
||||
hideActions?: ('fork' | 'delete')[];
|
||||
}
|
||||
|
||||
function StatsLine({ message }: { message: Message }) {
|
||||
@@ -226,7 +214,7 @@ function ActionRow({
|
||||
} else {
|
||||
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||
sessionEvents.emit({ type: 'refetch_messages' });
|
||||
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
||||
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'fork failed');
|
||||
@@ -258,54 +246,6 @@ function ActionRow({
|
||||
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
||||
const canFork = message.status === 'complete';
|
||||
const canDelete = message.status !== 'streaming';
|
||||
const [openingPane, setOpeningPane] = useState(false);
|
||||
|
||||
// v1.14.x-html-artifact-panes: probe for an html_artifact part. If present,
|
||||
// open the HTML pane variant; otherwise fall back to the markdown variant.
|
||||
// Title derivation for markdown: first `# ` heading → first 6 words of the
|
||||
// body → 'Markdown artifact' (mirrors the slug logic in
|
||||
// services/artifacts.ts).
|
||||
async function openInPane() {
|
||||
if (openingPane || message.status === 'streaming') return;
|
||||
setOpeningPane(true);
|
||||
try {
|
||||
try {
|
||||
const payload = await api.messages.getHtmlArtifact(
|
||||
message.chat_id,
|
||||
message.id,
|
||||
);
|
||||
sessionEvents.emit({
|
||||
type: 'open_html_artifact_pane',
|
||||
state: {
|
||||
chat_id: message.chat_id,
|
||||
message_id: message.id,
|
||||
title: payload.title,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
// 404 (no html_artifact part) is the expected fall-through path —
|
||||
// markdown variant opens below. Any other error (network, 500) is
|
||||
// a real failure; toast and bail rather than masquerading as markdown.
|
||||
const status = err instanceof ApiError ? err.status : null;
|
||||
if (status !== 404) {
|
||||
toast.error(err instanceof Error ? err.message : 'open in pane failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const title = deriveMarkdownTitle(message.content);
|
||||
sessionEvents.emit({
|
||||
type: 'open_markdown_artifact_pane',
|
||||
state: {
|
||||
chat_id: message.chat_id,
|
||||
message_id: message.id,
|
||||
title,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setOpeningPane(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -330,18 +270,6 @@ function ActionRow({
|
||||
<RefreshCw className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{isAssistant && !hiddenSet.has('openInPane') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openInPane()}
|
||||
disabled={openingPane || message.status === 'streaming'}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Open in pane"
|
||||
title="Open in pane"
|
||||
>
|
||||
<PanelRightOpen className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{isAssistant && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Archive, MessageSquare, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
import { api } from '@/api/client';
|
||||
import type { Chat } from '@/api/types';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
@@ -13,6 +16,30 @@ interface Props {
|
||||
// the skill — same transition the text send uses. See useSessionChats.
|
||||
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
|
||||
createChat: () => Promise<{ id: string }>;
|
||||
// Session history: the session's open chats (live), and callbacks to open one
|
||||
// in THIS pane / restore an archived one. Archived chats are fetched here
|
||||
// (the default open-only list excludes them).
|
||||
chats: Chat[];
|
||||
onOpenChat: (chatId: string) => void;
|
||||
onUnarchiveChat: (chatId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const s = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||
if (s < 60) return 'just now';
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.round(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function byRecent(a: Chat, b: Chat): number {
|
||||
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
|
||||
}
|
||||
|
||||
export function SessionLandingPage({
|
||||
@@ -23,8 +50,24 @@ export function SessionLandingPage({
|
||||
onSend,
|
||||
onSkillInvoke,
|
||||
createChat,
|
||||
chats,
|
||||
onOpenChat,
|
||||
onUnarchiveChat,
|
||||
}: Props) {
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
const [archived, setArchived] = useState<Chat[]>([]);
|
||||
|
||||
// Archived chats aren't in the default (open-only) list, so fetch them. One
|
||||
// shot on session change — the history view is transient (pick a chat and
|
||||
// it's gone), so slight staleness is fine; reopening the pane refetches.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.chats
|
||||
.listForSession(sessionId, { status: 'archived' })
|
||||
.then((list) => { if (!cancelled) setArchived(list); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionId]);
|
||||
|
||||
const ensureChat = useCallback(async (): Promise<string> => {
|
||||
if (chatId) return chatId;
|
||||
@@ -57,12 +100,87 @@ export function SessionLandingPage({
|
||||
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
||||
}, [onSkillInvoke]);
|
||||
|
||||
const restoreAndOpen = useCallback(async (id: string) => {
|
||||
try {
|
||||
await onUnarchiveChat(id);
|
||||
onOpenChat(id);
|
||||
} catch {
|
||||
// onUnarchiveChat surfaces its own toast.
|
||||
}
|
||||
}, [onUnarchiveChat, onOpenChat]);
|
||||
|
||||
const openChats = [...chats.filter((c) => c.status === 'open')].sort(byRecent);
|
||||
const openIds = new Set(openChats.map((c) => c.id));
|
||||
const archivedChats = archived.filter((c) => !openIds.has(c.id)).sort(byRecent);
|
||||
const isEmpty = openChats.length === 0 && archivedChats.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send a message to start.
|
||||
</p>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="max-w-[760px] mx-auto w-full px-4 py-4">
|
||||
{isEmpty ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No conversations yet. Send a message to start.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{openChats.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
|
||||
Conversations
|
||||
</h3>
|
||||
<div className="space-y-0.5 mb-4">
|
||||
{openChats.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => onOpenChat(c.id)}
|
||||
className="w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
|
||||
>
|
||||
<MessageSquare size={14} className="shrink-0 text-muted-foreground" />
|
||||
<span className="truncate shrink-0 max-w-[45%]">{c.name ?? 'New chat'}</span>
|
||||
{c.last_message_preview && (
|
||||
<span className="truncate flex-1 text-xs text-muted-foreground hidden sm:block">
|
||||
{c.last_message_preview}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 ml-auto text-xs text-muted-foreground">
|
||||
{formatRelative(c.updated_at)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{archivedChats.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
|
||||
Archived
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{archivedChats.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => void restoreAndOpen(c.id)}
|
||||
title="Restore and open"
|
||||
className="group/arch w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm text-muted-foreground max-md:min-h-[44px]"
|
||||
>
|
||||
<Archive size={14} className="shrink-0" />
|
||||
<span className="truncate flex-1">{c.name ?? 'New chat'}</span>
|
||||
<span className="shrink-0 text-xs">{formatRelative(c.updated_at)}</span>
|
||||
<RotateCcw
|
||||
size={13}
|
||||
className="shrink-0 opacity-0 group-hover/arch:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChatInput
|
||||
disabled={false}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function Workspace({
|
||||
}: Props) {
|
||||
const {
|
||||
panes,
|
||||
tabNumbers,
|
||||
activePaneIdx,
|
||||
setActivePaneIdx,
|
||||
openChatInPane,
|
||||
@@ -204,6 +205,7 @@ export function Workspace({
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
tabNumbers={tabNumbers}
|
||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||
@@ -390,6 +392,9 @@ export function Workspace({
|
||||
createChat={() => api.chats.create(sessionId)}
|
||||
onSend={(content) => void handleLandingSend(idx, content)}
|
||||
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
||||
chats={chats}
|
||||
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
||||
onUnarchiveChat={unarchiveChat}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -149,7 +149,7 @@ interface Props {
|
||||
actions?: MessageActions;
|
||||
}
|
||||
|
||||
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete' | 'openInPane')[] = ['fork', 'openInPane'];
|
||||
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete')[] = ['fork'];
|
||||
|
||||
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
Reference in New Issue
Block a user