feat: shared PaneHeaderActions + chat-resolve WorkspaceState fix (v2.7.7)

In-flight workspace UX work.

- Extract a shared PaneHeaderActions cluster (+/Split/Reopen/History/Close)
  used by ChatTabBar + the Workspace coder/terminal pane headers, replacing the
  divergent per-header copies; SessionLandingPage history + useWorkspacePanes
  tweaks.
- Fix coder-side correctness bug: resolveChatId read sessions.workspace_panes as
  a bare WorkspacePane[] but v2.6.5 widened it to a WorkspaceState envelope, so
  it mis-read panes and clobbered tabNumbers/nextTabNumber/closedPaneStack on
  every pane-chat write. New normalizeWorkspaceState handles either shape and
  preserves the envelope (+ regression test).
- CLAUDE.md doc-sync (coder vitest suite, deploy-by-surface, dual-remote push,
  in-flight-web-WIP staging, release-branch naming).

Web tsc + coder build + coder tests green. Builds on v2.7.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 14:28:49 +00:00
parent b7fb254e5d
commit c56d169ef9
9 changed files with 469 additions and 223 deletions

View File

@@ -1,7 +1,15 @@
import { useCallback, useEffect, useState } from 'react';
import { Archive, MessageSquare, RotateCcw } from 'lucide-react';
import { Archive, Code, MessageSquare, RotateCcw, Terminal, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { ChatInput } from '@/components/ChatInput';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { api } from '@/api/client';
import type { Chat } from '@/api/types';
@@ -22,6 +30,8 @@ interface Props {
chats: Chat[];
onOpenChat: (chatId: string) => void;
onUnarchiveChat: (chatId: string) => Promise<void>;
onArchiveChat: (chatId: string) => Promise<void>;
onDeleteChat: (chatId: string) => Promise<void>;
}
function formatRelative(iso: string): string {
@@ -42,6 +52,16 @@ function byRecent(a: Chat, b: Chat): number {
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
}
// Pick the row icon by the chat's seed name: coder and terminal panes create
// placeholder chats named 'BooCoder' / 'Terminal' (see useWorkspacePanes
// chatNameForPaneKind + the coder chat-resolve). A name heuristic keeps this
// frontend-only — matches ProjectSidebar's isCoderSessionName approach.
function iconForChat(name: string | null) {
if (name === 'BooCoder') return Code;
if (name === 'Terminal') return Terminal;
return MessageSquare;
}
export function SessionLandingPage({
projectId,
sessionId,
@@ -53,9 +73,13 @@ export function SessionLandingPage({
chats,
onOpenChat,
onUnarchiveChat,
onArchiveChat,
onDeleteChat,
}: Props) {
const [chatId, setChatId] = useState<string | null>(null);
const [archived, setArchived] = useState<Chat[]>([]);
// Plain Cancel/Confirm delete (no type-to-confirm), mirroring ProjectSidebar.
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string | null } | null>(null);
// 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
@@ -130,25 +154,52 @@ export function SessionLandingPage({
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>
))}
{openChats.map((c) => {
const Icon = iconForChat(c.name);
return (
<div
key={c.id}
className="group/row flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
>
<button
type="button"
onClick={() => onOpenChat(c.id)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<Icon 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 className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => { e.stopPropagation(); void onArchiveChat(c.id); }}
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-9"
aria-label="Archive chat"
title="Archive"
>
<Archive size={14} />
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }}
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:bg-destructive/20 hover:text-destructive max-md:size-9"
aria-label="Delete chat"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
</div>
</>
)}
@@ -159,21 +210,34 @@ export function SessionLandingPage({
</h3>
<div className="space-y-0.5">
{archivedChats.map((c) => (
<button
<div
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]"
className="group/arch flex items-center gap-2 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>
<button
type="button"
onClick={() => void restoreAndOpen(c.id)}
title="Restore and open"
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<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>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setDeleteConfirm({ id: c.id, name: c.name }); }}
className="shrink-0 inline-flex items-center justify-center size-7 rounded hover:bg-destructive/20 hover:text-destructive max-md:size-9 opacity-0 group-hover/arch:opacity-100 focus-within:opacity-100 transition-opacity"
aria-label="Delete chat"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</>
@@ -195,6 +259,31 @@ export function SessionLandingPage({
messages={[]}
modelContextLimit={null}
/>
<Dialog
open={deleteConfirm !== null}
onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat?</DialogTitle>
<DialogDescription>
Permanently deletes "{deleteConfirm?.name ?? 'New chat'}" and all its messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
setDeleteConfirm(null);
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}