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:
@@ -22,6 +22,7 @@ import type {
|
|||||||
CoderTaskDetail,
|
CoderTaskDetail,
|
||||||
PermissionPrompt,
|
PermissionPrompt,
|
||||||
AgentCommand,
|
AgentCommand,
|
||||||
|
WorkspaceState,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -175,10 +176,10 @@ export const api = {
|
|||||||
),
|
),
|
||||||
openChatsCount: (id: string) =>
|
openChatsCount: (id: string) =>
|
||||||
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
|
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
|
||||||
updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) =>
|
updateWorkspacePanes: (id: string, state: WorkspaceState) =>
|
||||||
request<Session>(`/api/sessions/${id}/workspace`, {
|
request<Session>(`/api/sessions/${id}/workspace`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ workspace_panes: panes }),
|
body: JSON.stringify({ workspace_panes: state }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -354,6 +355,10 @@ export const api = {
|
|||||||
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
||||||
getTask: (taskId: string) =>
|
getTask: (taskId: string) =>
|
||||||
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
|
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
|
||||||
|
// Cancel a pending/running coder task (cancels permission wait + inference;
|
||||||
|
// server sets state='cancelled'). Used by CoderPane's stop button.
|
||||||
|
cancelTask: (taskId: string) =>
|
||||||
|
request<{ cancelled: boolean }>(`/api/coder/tasks/${taskId}/cancel`, { method: 'POST' }),
|
||||||
listMessages: (sessionId: string, chatId?: string) =>
|
listMessages: (sessionId: string, chatId?: string) =>
|
||||||
request<CoderMessageWire[]>(
|
request<CoderMessageWire[]>(
|
||||||
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ export interface Session {
|
|||||||
// v1.9: null = inherit from project.default_web_search_enabled.
|
// v1.9: null = inherit from project.default_web_search_enabled.
|
||||||
web_search_enabled: boolean | null;
|
web_search_enabled: boolean | null;
|
||||||
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
||||||
workspace_panes: WorkspacePane[];
|
// A value may be the legacy bare WorkspacePane[] (older rows) OR the new
|
||||||
|
// WorkspaceState envelope (panes + tab numbering + reopen stack). Normalize
|
||||||
|
// on read via useWorkspacePanes' toWorkspaceState.
|
||||||
|
workspace_panes: WorkspacePane[] | WorkspaceState;
|
||||||
// v1.13.17: paths the agent has been granted read access to via the
|
// v1.13.17: paths the agent has been granted read access to via the
|
||||||
// request_read_access tool. Empty by default. Settings UI surfaces the
|
// request_read_access tool. Empty by default. Settings UI surfaces the
|
||||||
// list with per-row revoke; the grant flow itself appends through the
|
// list with per-row revoke; the grant flow itself appends through the
|
||||||
@@ -511,6 +514,30 @@ export interface WorkspacePane {
|
|||||||
html_artifact_state?: HtmlArtifactState;
|
html_artifact_state?: HtmlArtifactState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reopen LIFO stack entry. Shape unchanged from the prior module-level stack;
|
||||||
|
// now persisted inside the WorkspaceState envelope so the reopen-pane stack
|
||||||
|
// survives a reload / cross-device sync.
|
||||||
|
export interface ClosedPaneEntry {
|
||||||
|
kind: WorkspacePane['kind'];
|
||||||
|
chatIds: string[];
|
||||||
|
activeChatIdx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envelope persisted to sessions.workspace_panes. Supersedes the bare
|
||||||
|
// WorkspacePane[] shape (still accepted on read for legacy rows — see the
|
||||||
|
// migration in useWorkspacePanes.toWorkspaceState). The server accepts either
|
||||||
|
// shape; the frontend always emits this envelope going forward.
|
||||||
|
export interface WorkspaceState {
|
||||||
|
panes: WorkspacePane[];
|
||||||
|
// Stable, session-scoped tab number per chat id. Numbers only ever increase
|
||||||
|
// and are never reused (retired entries are pruned on tab close).
|
||||||
|
tabNumbers: { [chatId: string]: number };
|
||||||
|
// Next number to hand out; starts at 1; ONLY increments.
|
||||||
|
nextTabNumber: number;
|
||||||
|
// Reopen LIFO stack, max 10, most-recent last.
|
||||||
|
closedPaneStack: ClosedPaneEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export type WsFrame =
|
export type WsFrame =
|
||||||
| { type: 'snapshot'; messages: Message[] }
|
| { type: 'snapshot'; messages: Message[] }
|
||||||
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
|
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
|
||||||
|
|||||||
@@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({
|
|||||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||||
type: z.literal('session_workspace_updated'),
|
type: z.literal('session_workspace_updated'),
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
workspace_panes: z.array(OpaqueObject),
|
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
||||||
|
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
||||||
|
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
||||||
|
// every envelope frame at validation. MUST be mirrored in the server's
|
||||||
|
// byte-identical copy (parity test).
|
||||||
|
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ChatCreatedFrame = z.object({
|
export const ChatCreatedFrame = z.object({
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useLongPress } from '@/hooks/useLongPress';
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pane: WorkspacePane;
|
pane: WorkspacePane;
|
||||||
tabs: Chat[];
|
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;
|
onSwitchTab: (tabIdx: number) => void;
|
||||||
onRemoveTab: (chatId: string) => void;
|
onRemoveTab: (chatId: string) => void;
|
||||||
onCloseOthers: (chatId: string) => void;
|
onCloseOthers: (chatId: string) => void;
|
||||||
@@ -37,6 +41,7 @@ interface Props {
|
|||||||
export function ChatTabBar({
|
export function ChatTabBar({
|
||||||
pane,
|
pane,
|
||||||
tabs,
|
tabs,
|
||||||
|
tabNumbers,
|
||||||
onSwitchTab,
|
onSwitchTab,
|
||||||
onRemoveTab,
|
onRemoveTab,
|
||||||
onCloseOthers,
|
onCloseOthers,
|
||||||
@@ -83,6 +88,9 @@ export function ChatTabBar({
|
|||||||
const isLast = tabIdx === tabs.length - 1;
|
const isLast = tabIdx === tabs.length - 1;
|
||||||
const onlyTab = tabs.length === 1;
|
const onlyTab = tabs.length === 1;
|
||||||
const label = chat.name ?? 'New chat';
|
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 (
|
return (
|
||||||
<ContextMenu key={chat.id}>
|
<ContextMenu key={chat.id}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
@@ -117,8 +125,11 @@ export function ChatTabBar({
|
|||||||
className="bg-transparent border-b border-border text-xs outline-none w-28"
|
className="bg-transparent border-b border-border text-xs outline-none w-28"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="truncate max-w-[140px]" title={label}>
|
<span
|
||||||
{label}
|
className="truncate max-w-[140px]"
|
||||||
|
title={tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||||
|
>
|
||||||
|
{tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -138,6 +149,13 @@ export function ChatTabBar({
|
|||||||
<ContextMenuItem onSelect={onNewTab}>
|
<ContextMenuItem onSelect={onNewTab}>
|
||||||
New chat
|
New chat
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open in new pane
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
||||||
Rename
|
Rename
|
||||||
@@ -174,15 +192,31 @@ 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">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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]"
|
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"
|
aria-label="New chat, terminal, or coder"
|
||||||
title="New tab"
|
title="New chat / terminal / coder"
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ReactNode } 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 { toast } from 'sonner';
|
||||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
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 { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||||
import { CapHitSentinel } from './CapHitSentinel';
|
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
|
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
|
||||||
// panes can render assistant content with the same Shiki + remark-gfm setup.
|
// 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 {
|
export interface MessageActions {
|
||||||
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
|
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
|
||||||
onResend?: (chatId: string, content: string) => Promise<void>;
|
onResend?: (chatId: string, content: string) => Promise<void>;
|
||||||
@@ -129,8 +117,8 @@ interface Props {
|
|||||||
sessionChats?: Chat[];
|
sessionChats?: Chat[];
|
||||||
capHitInfo?: { position: number; isLatest: boolean };
|
capHitInfo?: { position: number; isLatest: boolean };
|
||||||
actions?: MessageActions;
|
actions?: MessageActions;
|
||||||
/** Hide actions that don't apply (fork, delete, open-in-pane). */
|
/** Hide actions that don't apply (fork, delete). */
|
||||||
hideActions?: ('fork' | 'delete' | 'openInPane')[];
|
hideActions?: ('fork' | 'delete')[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatsLine({ message }: { message: Message }) {
|
function StatsLine({ message }: { message: Message }) {
|
||||||
@@ -226,7 +214,7 @@ function ActionRow({
|
|||||||
} else {
|
} else {
|
||||||
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||||
sessionEvents.emit({ type: 'refetch_messages' });
|
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) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'fork failed');
|
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 canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
||||||
const canFork = message.status === 'complete';
|
const canFork = message.status === 'complete';
|
||||||
const canDelete = message.status !== 'streaming';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -330,18 +270,6 @@ function ActionRow({
|
|||||||
<RefreshCw className="size-3" />
|
<RefreshCw className="size-3" />
|
||||||
</button>
|
</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 && (
|
{isAssistant && (
|
||||||
<button
|
<button
|
||||||
type="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 { toast } from 'sonner';
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type { Chat } from '@/api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -13,6 +16,30 @@ interface Props {
|
|||||||
// the skill — same transition the text send uses. See useSessionChats.
|
// the skill — same transition the text send uses. See useSessionChats.
|
||||||
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
|
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
|
||||||
createChat: () => Promise<{ id: string }>;
|
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({
|
export function SessionLandingPage({
|
||||||
@@ -23,8 +50,24 @@ export function SessionLandingPage({
|
|||||||
onSend,
|
onSend,
|
||||||
onSkillInvoke,
|
onSkillInvoke,
|
||||||
createChat,
|
createChat,
|
||||||
|
chats,
|
||||||
|
onOpenChat,
|
||||||
|
onUnarchiveChat,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [chatId, setChatId] = useState<string | null>(null);
|
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> => {
|
const ensureChat = useCallback(async (): Promise<string> => {
|
||||||
if (chatId) return chatId;
|
if (chatId) return chatId;
|
||||||
@@ -57,12 +100,87 @@ export function SessionLandingPage({
|
|||||||
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
||||||
}, [onSkillInvoke]);
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex-1 flex items-center justify-center px-6">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="max-w-[760px] mx-auto w-full px-4 py-4">
|
||||||
Send a message to start.
|
{isEmpty ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No conversations yet. Send a message to start.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<ChatInput
|
<ChatInput
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function Workspace({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
panes,
|
panes,
|
||||||
|
tabNumbers,
|
||||||
activePaneIdx,
|
activePaneIdx,
|
||||||
setActivePaneIdx,
|
setActivePaneIdx,
|
||||||
openChatInPane,
|
openChatInPane,
|
||||||
@@ -204,6 +205,7 @@ export function Workspace({
|
|||||||
<ChatTabBar
|
<ChatTabBar
|
||||||
pane={pane}
|
pane={pane}
|
||||||
tabs={chatsForPane(pane)}
|
tabs={chatsForPane(pane)}
|
||||||
|
tabNumbers={tabNumbers}
|
||||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||||
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||||
@@ -390,6 +392,9 @@ export function Workspace({
|
|||||||
createChat={() => api.chats.create(sessionId)}
|
createChat={() => api.chats.create(sessionId)}
|
||||||
onSend={(content) => void handleLandingSend(idx, content)}
|
onSend={(content) => void handleLandingSend(idx, content)}
|
||||||
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
||||||
|
chats={chats}
|
||||||
|
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
||||||
|
onUnarchiveChat={unarchiveChat}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ interface Props {
|
|||||||
actions?: MessageActions;
|
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) {
|
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
||||||
const endRef = useRef<HTMLDivElement>(null);
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ export interface SessionUpdatedEvent {
|
|||||||
export interface SessionWorkspaceUpdatedEvent {
|
export interface SessionWorkspaceUpdatedEvent {
|
||||||
type: 'session_workspace_updated';
|
type: 'session_workspace_updated';
|
||||||
session_id: string;
|
session_id: string;
|
||||||
workspace_panes: import('@/api/types').WorkspacePane[];
|
// Legacy bare array OR the new envelope — useWorkspacePanes normalizes both
|
||||||
|
// via toWorkspaceState.
|
||||||
|
workspace_panes:
|
||||||
|
| import('@/api/types').WorkspacePane[]
|
||||||
|
| import('@/api/types').WorkspaceState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionLoadedEvent {
|
export interface SessionLoadedEvent {
|
||||||
@@ -75,6 +79,14 @@ export interface OpenChatInActivePaneEvent {
|
|||||||
chat_id: string;
|
chat_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open a whole chat in a fresh split pane (vs the active pane). Emitted by the
|
||||||
|
// ChatTabBar tab context menu ("Open in new pane") and by MessageBubble.fork()
|
||||||
|
// so a fork lands beside the original. useWorkspacePanes subscribes.
|
||||||
|
export interface OpenChatInNewPaneEvent {
|
||||||
|
type: 'open_chat_in_new_pane';
|
||||||
|
chat_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
|
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
|
||||||
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
|
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
|
||||||
// pane (or focuses an existing one keyed by message_id).
|
// pane (or focuses an existing one keyed by message_id).
|
||||||
@@ -178,6 +190,7 @@ export type SessionEvent =
|
|||||||
| OpenFileInBrowserEvent
|
| OpenFileInBrowserEvent
|
||||||
| AttachChatFileEvent
|
| AttachChatFileEvent
|
||||||
| OpenChatInActivePaneEvent
|
| OpenChatInActivePaneEvent
|
||||||
|
| OpenChatInNewPaneEvent
|
||||||
| OpenMarkdownArtifactPaneEvent
|
| OpenMarkdownArtifactPaneEvent
|
||||||
| OpenHtmlArtifactPaneEvent
|
| OpenHtmlArtifactPaneEvent
|
||||||
| OpenSettingsPaneEvent
|
| OpenSettingsPaneEvent
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
case 'attach_chat_file':
|
case 'attach_chat_file':
|
||||||
return prev;
|
return prev;
|
||||||
case 'open_chat_in_active_pane':
|
case 'open_chat_in_active_pane':
|
||||||
|
case 'open_chat_in_new_pane':
|
||||||
// Consumed by Workspace; sidebar has no business with pane state.
|
// Consumed by Workspace; sidebar has no business with pane state.
|
||||||
return prev;
|
return prev;
|
||||||
case 'open_markdown_artifact_pane':
|
case 'open_markdown_artifact_pane':
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import type { DragEvent } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type {
|
import type {
|
||||||
|
ClosedPaneEntry,
|
||||||
HtmlArtifactState,
|
HtmlArtifactState,
|
||||||
MarkdownArtifactState,
|
MarkdownArtifactState,
|
||||||
WorkspacePane,
|
WorkspacePane,
|
||||||
|
WorkspaceState,
|
||||||
} from '@/api/types';
|
} from '@/api/types';
|
||||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
@@ -32,19 +34,35 @@ 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 {
|
// v2.6.x: reopen stack cap. The stack now lives in React state (persisted in
|
||||||
kind: WorkspacePane['kind'];
|
// the WorkspaceState envelope), not a module-level array. `appendClosed` is the
|
||||||
chatIds: string[];
|
// pure state-updater helper.
|
||||||
activeChatIdx: number;
|
|
||||||
}
|
|
||||||
const MAX_CLOSED = 10;
|
const MAX_CLOSED = 10;
|
||||||
const closedPaneStack: ClosedPaneEntry[] = [];
|
|
||||||
|
|
||||||
function pushClosed(pane: WorkspacePane): void {
|
// Pure helper: append a closed-pane entry derived from `pane` to `stack`,
|
||||||
if (pane.kind === 'empty' || pane.kind === 'settings') return;
|
// capped at MAX_CLOSED (most-recent last). Returns the SAME reference when the
|
||||||
if (pane.chatIds.length === 0) return;
|
// pane is not eligible (empty/settings/no chats) so callers can skip setState.
|
||||||
closedPaneStack.push({ kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx });
|
function appendClosed(stack: ClosedPaneEntry[], pane: WorkspacePane): ClosedPaneEntry[] {
|
||||||
if (closedPaneStack.length > MAX_CLOSED) closedPaneStack.shift();
|
if (pane.kind === 'empty' || pane.kind === 'settings') return stack;
|
||||||
|
if (pane.chatIds.length === 0) return stack;
|
||||||
|
const entry = { kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx };
|
||||||
|
// Dedupe a value-identical top entry. This is called via setClosedPaneStack
|
||||||
|
// inside the setPanes updater in removePane; React StrictMode double-invokes
|
||||||
|
// that updater in dev, which would otherwise push two identical entries.
|
||||||
|
// Real closes never collide (one chat lives in at most one pane).
|
||||||
|
const top = stack[stack.length - 1];
|
||||||
|
if (
|
||||||
|
top &&
|
||||||
|
top.kind === entry.kind &&
|
||||||
|
top.activeChatIdx === entry.activeChatIdx &&
|
||||||
|
top.chatIds.length === entry.chatIds.length &&
|
||||||
|
top.chatIds.every((id, i) => id === entry.chatIds[i])
|
||||||
|
) {
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
const next = [...stack, entry];
|
||||||
|
if (next.length > MAX_CLOSED) next.splice(0, next.length - MAX_CLOSED);
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
|
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
|
||||||
@@ -110,6 +128,26 @@ function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
|
|||||||
return normalizePanes(panes).filter((p) => p.kind !== 'settings');
|
return normalizePanes(panes).filter((p) => p.kind !== 'settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.6.x: LOCKED migration — a value read from session.workspace_panes (or the
|
||||||
|
// session_workspace_updated frame) may be EITHER the legacy bare
|
||||||
|
// WorkspacePane[] OR the new WorkspaceState envelope. Normalize to the
|
||||||
|
// envelope. Must match the server's normalization byte-for-byte.
|
||||||
|
function toWorkspaceState(raw: unknown): WorkspaceState {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return { panes: raw as WorkspacePane[], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
|
||||||
|
}
|
||||||
|
if (raw && typeof raw === 'object' && Array.isArray((raw as WorkspaceState).panes)) {
|
||||||
|
const env = raw as WorkspaceState;
|
||||||
|
return {
|
||||||
|
panes: env.panes,
|
||||||
|
tabNumbers: env.tabNumbers ?? {},
|
||||||
|
nextTabNumber: env.nextTabNumber ?? 1,
|
||||||
|
closedPaneStack: env.closedPaneStack ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { panes: [], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
|
||||||
|
}
|
||||||
|
|
||||||
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
|
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
|
||||||
// Helper used at every pane-insertion site so the rule lives in one place.
|
// Helper used at every pane-insertion site so the rule lives in one place.
|
||||||
function nonSettingsCount(panes: WorkspacePane[]): number {
|
function nonSettingsCount(panes: WorkspacePane[]): number {
|
||||||
@@ -132,6 +170,9 @@ function readLegacyPanes(sessionId: string): WorkspacePane[] | null {
|
|||||||
|
|
||||||
export interface UseWorkspacePanesResult {
|
export interface UseWorkspacePanesResult {
|
||||||
panes: WorkspacePane[];
|
panes: WorkspacePane[];
|
||||||
|
// v2.6.x: stable session-scoped tab number per chat id (Batch 3a). Keyed by
|
||||||
|
// chat.id, NEVER by tab position.
|
||||||
|
tabNumbers: Record<string, number>;
|
||||||
activePaneIdx: number;
|
activePaneIdx: number;
|
||||||
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
|
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
|
||||||
activePaneIdxRef: React.MutableRefObject<number>;
|
activePaneIdxRef: React.MutableRefObject<number>;
|
||||||
@@ -171,6 +212,12 @@ export interface UseWorkspacePanesResult {
|
|||||||
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||||
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
|
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
|
||||||
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
||||||
|
// v2.6.x envelope state. Persisted alongside `panes` in the WorkspaceState
|
||||||
|
// envelope. `tabNumbers` is the stable session-scoped tab number per chat id;
|
||||||
|
// `nextTabNumber` only ever increments; `closedPaneStack` is the reopen LIFO.
|
||||||
|
const [tabNumbers, setTabNumbers] = useState<Record<string, number>>({});
|
||||||
|
const [nextTabNumber, setNextTabNumber] = useState(1);
|
||||||
|
const [closedPaneStack, setClosedPaneStack] = useState<ClosedPaneEntry[]>([]);
|
||||||
const draggingIdxRef = useRef<number | null>(null);
|
const draggingIdxRef = useRef<number | null>(null);
|
||||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||||
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
|
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
|
||||||
@@ -237,27 +284,42 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
try {
|
try {
|
||||||
const session = await api.sessions.get(sessionId);
|
const session = await api.sessions.get(sessionId);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
let initial: WorkspacePane[] = Array.isArray(session.workspace_panes)
|
let env = toWorkspaceState(session.workspace_panes);
|
||||||
? normalizePanes(session.workspace_panes)
|
let initial: WorkspacePane[] = normalizePanes(env.panes);
|
||||||
: [];
|
|
||||||
// One-time migration: if server is empty but legacy localStorage has
|
// One-time migration: if server is empty but legacy localStorage has
|
||||||
// a layout, seed the server and delete the local key.
|
// a layout, seed the server (as an envelope) and delete the local key.
|
||||||
if (initial.length === 0) {
|
if (initial.length === 0) {
|
||||||
const legacy = readLegacyPanes(sessionId);
|
const legacy = readLegacyPanes(sessionId);
|
||||||
if (legacy && legacy.length > 0) {
|
if (legacy && legacy.length > 0) {
|
||||||
try {
|
try {
|
||||||
const updated = await api.sessions.updateWorkspacePanes(sessionId, legacy);
|
const seedState: WorkspaceState = {
|
||||||
|
panes: persistablePanes(legacy),
|
||||||
|
tabNumbers: {},
|
||||||
|
nextTabNumber: 1,
|
||||||
|
closedPaneStack: [],
|
||||||
|
};
|
||||||
|
const updated = await api.sessions.updateWorkspacePanes(sessionId, seedState);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
initial = updated.workspace_panes;
|
env = toWorkspaceState(updated.workspace_panes);
|
||||||
|
initial = normalizePanes(env.panes);
|
||||||
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
|
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
|
||||||
} catch {
|
} catch {
|
||||||
initial = legacy;
|
env = { ...env, panes: legacy };
|
||||||
|
initial = normalizePanes(legacy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const next = initial.length > 0 ? initial : [emptyPane()];
|
const next = initial.length > 0 ? initial : [emptyPane()];
|
||||||
lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next));
|
lastRemoteJsonRef.current = JSON.stringify({
|
||||||
|
panes: persistablePanes(next),
|
||||||
|
tabNumbers: env.tabNumbers,
|
||||||
|
nextTabNumber: env.nextTabNumber,
|
||||||
|
closedPaneStack: env.closedPaneStack,
|
||||||
|
});
|
||||||
setPanes(next);
|
setPanes(next);
|
||||||
|
setTabNumbers(env.tabNumbers);
|
||||||
|
setNextTabNumber(env.nextTabNumber);
|
||||||
|
setClosedPaneStack(env.closedPaneStack);
|
||||||
setActivePaneIdx(0);
|
setActivePaneIdx(0);
|
||||||
seedEmptyScopedPanes(next);
|
seedEmptyScopedPanes(next);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -273,15 +335,25 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
return sessionEvents.subscribe((ev) => {
|
return sessionEvents.subscribe((ev) => {
|
||||||
if (ev.type !== 'session_workspace_updated') return;
|
if (ev.type !== 'session_workspace_updated') return;
|
||||||
if (ev.session_id !== sessionId) return;
|
if (ev.session_id !== sessionId) return;
|
||||||
const incoming = normalizePanes(
|
const env = toWorkspaceState(ev.workspace_panes);
|
||||||
Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [],
|
const incoming = normalizePanes(env.panes);
|
||||||
);
|
// Echo-dedup on the FULL envelope so tabNumber / stack-only changes are
|
||||||
const json = JSON.stringify(incoming);
|
// not mistaken for our own write echo.
|
||||||
|
const json = JSON.stringify({
|
||||||
|
panes: persistablePanes(incoming),
|
||||||
|
tabNumbers: env.tabNumbers,
|
||||||
|
nextTabNumber: env.nextTabNumber,
|
||||||
|
closedPaneStack: env.closedPaneStack,
|
||||||
|
});
|
||||||
if (json === lastRemoteJsonRef.current) return;
|
if (json === lastRemoteJsonRef.current) return;
|
||||||
lastRemoteJsonRef.current = json;
|
lastRemoteJsonRef.current = json;
|
||||||
setPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
const nextPanes = incoming.length > 0 ? incoming : [emptyPane()];
|
||||||
|
setPanes(nextPanes);
|
||||||
|
setTabNumbers(env.tabNumbers);
|
||||||
|
setNextTabNumber(env.nextTabNumber);
|
||||||
|
setClosedPaneStack(env.closedPaneStack);
|
||||||
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
|
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
|
||||||
seedEmptyScopedPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
seedEmptyScopedPanes(nextPanes);
|
||||||
});
|
});
|
||||||
}, [sessionId, seedEmptyScopedPanes]);
|
}, [sessionId, seedEmptyScopedPanes]);
|
||||||
|
|
||||||
@@ -333,18 +405,75 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
// before saving (ephemeral per v1.9).
|
// before saving (ephemeral per v1.9).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydratedRef.current) return;
|
if (!hydratedRef.current) return;
|
||||||
const payload = persistablePanes(panes);
|
// v2.6.x: persist the full WorkspaceState envelope. The dedup ref compares
|
||||||
const json = JSON.stringify(payload);
|
// the whole envelope so tabNumber / reopen-stack changes also persist.
|
||||||
|
const envelope: WorkspaceState = {
|
||||||
|
panes: persistablePanes(panes),
|
||||||
|
tabNumbers,
|
||||||
|
nextTabNumber,
|
||||||
|
closedPaneStack,
|
||||||
|
};
|
||||||
|
const json = JSON.stringify(envelope);
|
||||||
if (json === lastRemoteJsonRef.current) return;
|
if (json === lastRemoteJsonRef.current) return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
lastRemoteJsonRef.current = json;
|
lastRemoteJsonRef.current = json;
|
||||||
api.sessions.updateWorkspacePanes(sessionId, payload).catch(() => {
|
api.sessions.updateWorkspacePanes(sessionId, envelope).catch(() => {
|
||||||
// Non-fatal: next change retries. Persistent failures surface via
|
// Non-fatal: next change retries. Persistent failures surface via
|
||||||
// the network layer's existing reconnect toast.
|
// the network layer's existing reconnect toast.
|
||||||
});
|
});
|
||||||
}, SAVE_DEBOUNCE_MS);
|
}, SAVE_DEBOUNCE_MS);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [sessionId, panes]);
|
}, [sessionId, panes, tabNumbers, nextTabNumber, closedPaneStack]);
|
||||||
|
|
||||||
|
// v2.6.x (Batch 3a): maintain stable, session-scoped tab numbers. Collect the
|
||||||
|
// chat ids that appear in CHAT-kind panes in deterministic order (pane index,
|
||||||
|
// then tab index). Assign numbers to any without one (global per session,
|
||||||
|
// only ever increasing, never reused) and prune entries whose chat is no
|
||||||
|
// longer in any chat-kind pane. Guarded against render loops: only setState
|
||||||
|
// when something actually changed.
|
||||||
|
useEffect(() => {
|
||||||
|
const liveChatIds: string[] = [];
|
||||||
|
const liveSet = new Set<string>();
|
||||||
|
for (const pane of panes) {
|
||||||
|
if (pane.kind !== 'chat') continue;
|
||||||
|
for (const id of pane.chatIds) {
|
||||||
|
if (!liveSet.has(id)) {
|
||||||
|
liveSet.add(id);
|
||||||
|
liveChatIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign: walk live ids in deterministic order, handing out numbers.
|
||||||
|
let counter = nextTabNumber;
|
||||||
|
const additions: Record<string, number> = {};
|
||||||
|
for (const id of liveChatIds) {
|
||||||
|
if (tabNumbers[id] === undefined && additions[id] === undefined) {
|
||||||
|
additions[id] = counter;
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune: retire numbers for chats no longer in any chat-kind pane.
|
||||||
|
const removals: string[] = [];
|
||||||
|
for (const id of Object.keys(tabNumbers)) {
|
||||||
|
if (!liveSet.has(id)) removals.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAdditions = Object.keys(additions).length > 0;
|
||||||
|
const hasRemovals = removals.length > 0;
|
||||||
|
if (!hasAdditions && !hasRemovals) return;
|
||||||
|
|
||||||
|
setTabNumbers((prev) => {
|
||||||
|
const next: Record<string, number> = {};
|
||||||
|
for (const [id, n] of Object.entries(prev)) {
|
||||||
|
if (!removals.includes(id)) next[id] = n;
|
||||||
|
}
|
||||||
|
Object.assign(next, additions);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (hasAdditions) setNextTabNumber(counter);
|
||||||
|
}, [panes, tabNumbers, nextTabNumber]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const active = panes[activePaneIdx];
|
const active = panes[activePaneIdx];
|
||||||
@@ -391,6 +520,37 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setActivePaneIdx(paneIdx);
|
setActivePaneIdx(paneIdx);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Open a whole chat in its own fresh pane (focused). Detaches the chat from
|
||||||
|
// any pane currently showing it so it lives in exactly one pane (preserves
|
||||||
|
// the one-chat-per-pane model), dropping a source pane left with no tabs. For
|
||||||
|
// fork the chat isn't in any pane yet, so the detach is a no-op (pure append).
|
||||||
|
const openChatInNewPane = useCallback((chatId: string) => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
const detached = prev.flatMap((p) => {
|
||||||
|
if (!p.chatIds.includes(chatId)) return [p];
|
||||||
|
const nextIds = p.chatIds.filter((id) => id !== chatId);
|
||||||
|
if (nextIds.length === 0) return [];
|
||||||
|
const ai = Math.min(p.activeChatIdx, nextIds.length - 1);
|
||||||
|
return [{ ...p, kind: 'chat' as const, chatId: nextIds[ai], chatIds: nextIds, activeChatIdx: ai }];
|
||||||
|
});
|
||||||
|
if (nonSettingsCount(detached) >= MAX_PANES) {
|
||||||
|
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = [...detached, chatPane(chatId)];
|
||||||
|
setActivePaneIdx(next.length - 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ChatTabBar's "Open in new pane" + MessageBubble.fork() emit this.
|
||||||
|
useEffect(() => {
|
||||||
|
return sessionEvents.subscribe((ev) => {
|
||||||
|
if (ev.type !== 'open_chat_in_new_pane') return;
|
||||||
|
openChatInNewPane(ev.chat_id);
|
||||||
|
});
|
||||||
|
}, [openChatInNewPane]);
|
||||||
|
|
||||||
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
@@ -411,7 +571,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);
|
setClosedPaneStack((stack) => appendClosed(stack, pane));
|
||||||
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;
|
||||||
@@ -547,7 +707,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
if (prev.length <= 1) {
|
if (prev.length <= 1) {
|
||||||
// Settings is the only kind that can be the last pane and still need
|
// Settings is the only kind that can be the last pane and still need
|
||||||
// closing (X / Esc / sidebar toggle). Fall back to empty.
|
// closing (X / Esc / sidebar toggle). Fall back to empty. One-pane
|
||||||
|
// edge: no relocation — there is no other pane.
|
||||||
if (prev[idx]?.kind === 'settings') {
|
if (prev[idx]?.kind === 'settings') {
|
||||||
setActivePaneIdx(0);
|
setActivePaneIdx(0);
|
||||||
return [emptyPane()];
|
return [emptyPane()];
|
||||||
@@ -559,35 +720,101 @@ 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); }
|
// Push the original pane (with its chatIds intact) to the reopen stack.
|
||||||
|
if (removed) setClosedPaneStack((stack) => appendClosed(stack, removed));
|
||||||
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 */ });
|
||||||
}
|
}
|
||||||
const next = prev.filter((_, i) => i !== idx);
|
|
||||||
|
// v2.6.x (Batch 1): relocate a closing CHAT pane's tabs to the oldest
|
||||||
|
// remaining pane that can host chat tabs, so chats aren't lost on close.
|
||||||
|
// Only chat panes relocate — terminal/coder panes own a scoped chat bound
|
||||||
|
// to the pane, so those close exactly as before (no relocation).
|
||||||
|
let working = prev;
|
||||||
|
if (removed && removed.kind === 'chat' && removed.chatIds.length > 0) {
|
||||||
|
// "Oldest remaining": lowest index, excluding `idx`, that is a chat or
|
||||||
|
// empty pane (the only kinds that can host arbitrary chat tabs). Skip
|
||||||
|
// terminal/coder/settings/artifact panes.
|
||||||
|
let targetIdx = -1;
|
||||||
|
for (let i = 0; i < prev.length; i += 1) {
|
||||||
|
if (i === idx) continue;
|
||||||
|
const p = prev[i]!;
|
||||||
|
if (p.kind === 'chat' || p.kind === 'empty') {
|
||||||
|
targetIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetIdx >= 0) {
|
||||||
|
working = prev.map((p, i) => {
|
||||||
|
if (i !== targetIdx) return p;
|
||||||
|
const mergedIds = [...p.chatIds, ...removed.chatIds];
|
||||||
|
// Preserve the target's existing focus — append, don't force-focus
|
||||||
|
// the moved tabs. Clamp only when the target had no active tab.
|
||||||
|
const ai = p.activeChatIdx >= 0 ? p.activeChatIdx : 0;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
kind: 'chat' as const,
|
||||||
|
chatIds: mergedIds,
|
||||||
|
activeChatIdx: ai,
|
||||||
|
chatId: mergedIds[ai],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = working.filter((_, i) => i !== idx);
|
||||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const [hasClosedPanes, setHasClosedPanes] = useState(closedPaneStack.length > 0);
|
const hasClosedPanes = closedPaneStack.length > 0;
|
||||||
|
|
||||||
const reopenPane = useCallback(() => {
|
const reopenPane = useCallback(() => {
|
||||||
const entry = closedPaneStack.pop();
|
// Read the top entry from the current render's stack (not inside the
|
||||||
setHasClosedPanes(closedPaneStack.length > 0);
|
// updater) so a StrictMode double-invoke can't pop two entries. The pop
|
||||||
if (!entry) return;
|
// setState is idempotent: filtering by reference removes exactly this entry.
|
||||||
|
const e = closedPaneStack[closedPaneStack.length - 1];
|
||||||
|
if (!e) return;
|
||||||
|
setClosedPaneStack((stack) => (stack[stack.length - 1] === e ? stack.slice(0, -1) : stack));
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
|
// v2.6.x (Batch 4): reversible reopen. The closed tabs may have been
|
||||||
|
// relocated into another pane on close (Batch 1). Strip e.chatIds from
|
||||||
|
// every existing pane first so reopening never duplicates a tab —
|
||||||
|
// whether or not it was relocated (a no-op strip when it wasn't). Mirror
|
||||||
|
// removeTab's emptiness handling: a chat pane emptied by the strip is
|
||||||
|
// dropped when other panes remain, else turned empty.
|
||||||
|
const stripped: WorkspacePane[] = [];
|
||||||
|
for (const p of prev) {
|
||||||
|
const idxs = p.chatIds.filter((id) => !e.chatIds.includes(id));
|
||||||
|
if (idxs.length === p.chatIds.length) {
|
||||||
|
stripped.push(p);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (idxs.length === 0) {
|
||||||
|
if (p.kind === 'chat') {
|
||||||
|
// Drop the now-empty chat pane (we still have the restored pane plus
|
||||||
|
// possibly others). If it would leave zero panes, turn it empty.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stripped.push({ ...p, chatId: undefined, chatIds: [], activeChatIdx: -1 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ai = Math.min(p.activeChatIdx, idxs.length - 1);
|
||||||
|
stripped.push({ ...p, chatIds: idxs, activeChatIdx: ai < 0 ? 0 : ai, chatId: idxs[ai < 0 ? 0 : ai] });
|
||||||
|
}
|
||||||
const restored: WorkspacePane = {
|
const restored: WorkspacePane = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
kind: entry.kind,
|
kind: e.kind,
|
||||||
chatId: entry.chatIds[entry.activeChatIdx] ?? entry.chatIds[0],
|
chatId: e.chatIds[e.activeChatIdx] ?? e.chatIds[0],
|
||||||
chatIds: entry.chatIds,
|
chatIds: e.chatIds,
|
||||||
activeChatIdx: Math.min(entry.activeChatIdx, entry.chatIds.length - 1),
|
activeChatIdx: Math.min(e.activeChatIdx, e.chatIds.length - 1),
|
||||||
};
|
};
|
||||||
const next = [...prev, restored];
|
const next = [...stripped, restored];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [closedPaneStack]);
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -705,6 +932,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
panes,
|
panes,
|
||||||
|
tabNumbers,
|
||||||
activePaneIdx,
|
activePaneIdx,
|
||||||
setActivePaneIdx,
|
setActivePaneIdx,
|
||||||
activePaneIdxRef,
|
activePaneIdxRef,
|
||||||
|
|||||||
Reference in New Issue
Block a user