From 78ab5ab40b9e29e18b6682dd79758c652dcea25a Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Fri, 24 Apr 2026 13:51:09 +0200 Subject: [PATCH 1/7] Refactor session tab bar --- apps/web/src/app/layouts/ChatArea.tsx | 25 +- apps/web/src/app/layouts/useChatTabs.ts | 232 ++++++------ apps/web/src/features/session/index.ts | 3 +- apps/web/src/features/session/ui/index.ts | 1 + .../session/ui/tabs/ClosedSessionsPopover.tsx | 79 ++++ .../features/session/ui/tabs/SessionTab.tsx | 112 ++++++ .../session/ui/tabs/SessionTabBar.tsx | 232 ++++++++++++ .../session/ui/tabs/SortableSessionTab.tsx | 39 ++ .../web/src/features/session/ui/tabs/index.ts | 3 + .../web/src/features/session/ui/tabs/types.ts | 37 ++ .../features/workspace/ui/MainContentTabs.tsx | 344 ------------------ .../src/features/workspace/ui/SortableTab.tsx | 44 --- apps/web/src/features/workspace/ui/index.ts | 1 - 13 files changed, 642 insertions(+), 510 deletions(-) create mode 100644 apps/web/src/features/session/ui/tabs/ClosedSessionsPopover.tsx create mode 100644 apps/web/src/features/session/ui/tabs/SessionTab.tsx create mode 100644 apps/web/src/features/session/ui/tabs/SessionTabBar.tsx create mode 100644 apps/web/src/features/session/ui/tabs/SortableSessionTab.tsx create mode 100644 apps/web/src/features/session/ui/tabs/index.ts create mode 100644 apps/web/src/features/session/ui/tabs/types.ts delete mode 100644 apps/web/src/features/workspace/ui/MainContentTabs.tsx delete mode 100644 apps/web/src/features/workspace/ui/SortableTab.tsx diff --git a/apps/web/src/app/layouts/ChatArea.tsx b/apps/web/src/app/layouts/ChatArea.tsx index 67cbbe13b..18fec7caa 100644 --- a/apps/web/src/app/layouts/ChatArea.tsx +++ b/apps/web/src/app/layouts/ChatArea.tsx @@ -7,12 +7,16 @@ import { useMemo, useCallback, useEffect, useRef } from "react"; import type { Dispatch, SetStateAction } from "react"; -import { SessionPanel } from "@/features/session"; +import { + SessionPanel, + SessionTabBar, + getChatTabSessionId, + isSessionChatTab, +} from "@/features/session"; import type { SessionPanelRef } from "@/features/session"; import { useWorkingSessionIds } from "@/features/session/api/session.queries"; import { useUnreadStore, unreadActions } from "@/features/session/store/unreadStore"; import { WorkspaceEmptyState } from "@/features/session/ui/WorkspaceEmptyState"; -import { MainContentTabBar } from "@/features/workspace"; import type { Workspace } from "@/shared/types"; import { useChatTabs } from "./useChatTabs"; @@ -38,6 +42,7 @@ export function ChatArea({ activeTabId, activeTab, closedTabs, + focusActiveTabKey, handleTabChange, handleTabClose, handleTabAdd, @@ -51,19 +56,19 @@ export function ChatArea({ }); // Resolve sessionId: tab's own session, falling back to workspace's active session - const tabSessionId = activeTab?.data?.sessionId || workspace.current_session_id; + const tabSessionId = getChatTabSessionId(activeTab) || workspace.current_session_id; // Per-tab working status: subscribes to each chat tab's session detail cache // so each tab's spinner reflects its own session's status (not the workspace's // single session_status which breaks with multiple tabs). const chatSessionIds = useMemo( - () => tabs.filter((t) => t.data?.sessionId).map((t) => t.data!.sessionId!), + () => tabs.filter(isSessionChatTab).map((tab) => tab.sessionId), [tabs] ); const workingSessionIds = useWorkingSessionIds(chatSessionIds); // Mark non-active tab sessions as unread when they leave the working set. - const activeSessionId = activeTab?.data?.sessionId; + const activeSessionId = getChatTabSessionId(activeTab); const prevWorkingRef = useRef(workingSessionIds); const prevWorkspaceRef = useRef(workspace.id); useEffect(() => { @@ -92,8 +97,9 @@ export function ChatArea({ const handleTabChangeWithRead = useCallback( (tabId: string) => { const tab = tabs.find((t) => t.id === tabId); - if (tab?.data?.sessionId) { - unreadActions.markRead(tab.data.sessionId); + const sessionId = getChatTabSessionId(tab); + if (sessionId) { + unreadActions.markRead(sessionId); } handleTabChange(tabId); }, @@ -102,11 +108,12 @@ export function ChatArea({ return (
- activeTab && updateChatTabAgentHarness(activeTab.id, agentHarness) } diff --git a/apps/web/src/app/layouts/useChatTabs.ts b/apps/web/src/app/layouts/useChatTabs.ts index 30467a60e..ec851bbba 100644 --- a/apps/web/src/app/layouts/useChatTabs.ts +++ b/apps/web/src/app/layouts/useChatTabs.ts @@ -12,38 +12,69 @@ import { useCreateSession, useWorkspaceSessions } from "@/features/session/api/s import { getAgentLabel, getAgentHarnessForModel, type AgentHarness } from "@/shared/agents"; import { workspaceLayoutActions } from "@/features/workspace/store/workspaceLayoutStore"; import { sessionComposerActions } from "@/features/session/store/sessionComposerStore"; -import type { Tab, ClosedTab } from "@/features/workspace/ui/MainContentTabs"; import type { Session } from "@/features/session/types"; +import type { + ChatTab, + ClosedSessionTab, + PendingChatTab, + SessionChatTab, +} from "@/features/session/ui/tabs"; +import { getChatTabSessionId, isSessionChatTab } from "@/features/session/ui/tabs"; const NEW_CHAT_LABEL = "New chat"; const MAX_CLOSED_TABS = 20; -function buildStartedChatLabel(agentHarness: string, sequence: number): string { +function buildStartedChatLabel(agentHarness: AgentHarness, sequence: number): string { return `${getAgentLabel(agentHarness)} #${sequence}`; } -/** Build a Tab from a session record. Sequence is computed externally. */ -function sessionToTab(session: Session, sequence: number): Tab { +function createPendingTab(): PendingChatTab { + return { + kind: "pending", + id: "tab-default", + label: NEW_CHAT_LABEL, + agentHarness: "claude", + hasStarted: false, + }; +} + +function createPlaceholderSessionTab( + sessionId: string, + agentHarness: AgentHarness = "claude", + initialModel?: string +): SessionChatTab { + return { + kind: "session", + id: `tab-${sessionId}`, + sessionId, + label: NEW_CHAT_LABEL, + agentHarness, + hasStarted: false, + initialModel, + }; +} + +/** Build a tab from a session record. Sequence is computed externally. */ +function sessionToTab(session: Session, sequence: number): SessionChatTab { const hasStarted = session.message_count > 0; return { + kind: "session", id: `tab-${session.id}`, + sessionId: session.id, label: hasStarted ? buildStartedChatLabel(session.agent_harness, sequence) : NEW_CHAT_LABEL, - data: { - sessionId: session.id, - agentHarness: session.agent_harness, - hasStarted, - }, + agentHarness: session.agent_harness, + hasStarted, }; } /** Count started tabs of a given agent type, excluding a specific tab. */ function countStartedTabsOfHarness( - tabs: Tab[], - agentHarness: string, + tabs: ChatTab[], + agentHarness: AgentHarness, excludeTabId: string ): number { return tabs.filter( - (t) => t.id !== excludeTabId && t.data?.hasStarted && t.data?.agentHarness === agentHarness + (tab) => tab.id !== excludeTabId && tab.hasStarted && tab.agentHarness === agentHarness ).length; } @@ -81,35 +112,15 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions // --- Hydrate initial state from localStorage + session data --- - const [mainTabs, setMainTabs] = useState(() => { + const [mainTabs, setMainTabs] = useState(() => { const layout = workspaceLayoutActions.getLayout(workspaceId); const persistedIds = layout.chatTabSessionIds; if (persistedIds.length === 0) { - // First mount or migration — single tab with workspace's active session - return [ - { - id: activeSessionId ? `tab-${activeSessionId}` : "tab-default", - label: NEW_CHAT_LABEL, - data: { - sessionId: activeSessionId ?? undefined, - agentHarness: "claude", - hasStarted: false, - }, - }, - ]; + return [activeSessionId ? createPlaceholderSessionTab(activeSessionId) : createPendingTab()]; } - // Create placeholder tabs from persisted IDs (synchronous, instant) - return persistedIds.map((sessionId) => ({ - id: `tab-${sessionId}`, - label: NEW_CHAT_LABEL, - data: { - sessionId, - agentHarness: "claude", - hasStarted: false, - }, - })); + return persistedIds.map((sessionId) => createPlaceholderSessionTab(sessionId)); }); const [activeMainTabId, setActiveMainTabId] = useState(() => { @@ -120,7 +131,8 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions return activeSessionId ? `tab-${activeSessionId}` : "tab-default"; }); - const [closedTabs, setClosedTabs] = useState([]); + const [closedTabs, setClosedTabs] = useState([]); + const [focusActiveTabKey, setFocusActiveTabKey] = useState(0); const createSessionMutation = useCreateSession(); @@ -144,11 +156,9 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions const updated = [...tabs]; updated[defaultIdx] = { ...updated[defaultIdx], + kind: "session", id: `tab-${activeSessionId}`, - data: { - ...updated[defaultIdx].data, - sessionId: activeSessionId, - }, + sessionId: activeSessionId, }; return updated; }); @@ -166,40 +176,36 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions // eslint-disable-next-line react-hooks/set-state-in-effect -- one-time hydration sync from DB setMainTabs((prev) => { // Filter out orphaned session IDs (deleted from DB) - const validTabs = prev.filter((t) => { - const sid = t.data?.sessionId; - return sid && sessionMap.has(sid); - }); + const validTabs = prev.filter( + (tab) => !isSessionChatTab(tab) || sessionMap.has(tab.sessionId) + ); if (validTabs.length === 0) { // All persisted sessions are gone — fall back to active session const fallbackId = activeSessionId; const fallbackSession = fallbackId ? sessionMap.get(fallbackId) : undefined; const seq = fallbackSession ? computeSequences([fallbackSession]) : new Map(); - const tab = fallbackSession - ? sessionToTab(fallbackSession, seq.get(fallbackId!) ?? 1) - : { - id: fallbackId ? `tab-${fallbackId}` : "tab-default", - label: NEW_CHAT_LABEL, - data: { - sessionId: fallbackId ?? undefined, - agentHarness: "claude", - hasStarted: false, - }, - }; + const tab = + fallbackSession && fallbackId + ? sessionToTab(fallbackSession, seq.get(fallbackId) ?? 1) + : fallbackId + ? createPlaceholderSessionTab(fallbackId) + : createPendingTab(); setActiveMainTabId(tab.id); return [tab]; } // Compute sequences across all valid sessions in tab order const orderedSessions = validTabs - .map((t) => sessionMap.get(t.data!.sessionId!)!) + .filter(isSessionChatTab) + .map((tab) => sessionMap.get(tab.sessionId)!) .filter(Boolean); const sequences = computeSequences(orderedSessions); // Hydrate each placeholder with real session data const hydratedTabs = validTabs.map((t) => { - const session = sessionMap.get(t.data!.sessionId!)!; + if (!isSessionChatTab(t)) return t; + const session = sessionMap.get(t.sessionId)!; return sessionToTab(session, sequences.get(session.id) ?? 1); }); @@ -220,12 +226,10 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions useEffect(() => { clearTimeout(persistTimeoutRef.current); persistTimeoutRef.current = setTimeout(() => { - const chatSessionIds = mainTabs - .filter((t) => t.data?.sessionId) - .map((t) => t.data!.sessionId!); + const chatSessionIds = mainTabs.filter(isSessionChatTab).map((tab) => tab.sessionId); const activeTab = mainTabs.find((t) => t.id === activeMainTabId); - const activeSessionIdForPersist = activeTab?.data?.sessionId ?? null; + const activeSessionIdForPersist = getChatTabSessionId(activeTab); workspaceLayoutActions.setChatTabState( workspaceId, @@ -250,29 +254,25 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions const currentIndex = prev.findIndex((t) => t.id === tabId); const newTabs = prev.filter((t) => t.id !== tabId); - // Save closed tab for restore — preserve initialModel so restoring - // an unopened tab keeps the user's model choice (required downstream). - if (closingTab?.data?.sessionId) { + if (closingTab && isSessionChatTab(closingTab)) { setClosedTabs((prevClosed) => { - const entry: ClosedTab = { + const entry: ClosedSessionTab = { label: closingTab.label, - sessionId: closingTab.data!.sessionId!, - agentHarness: closingTab.data?.agentHarness, - initialModel: closingTab.data?.initialModel, + sessionId: closingTab.sessionId, + agentHarness: closingTab.agentHarness, + hasStarted: closingTab.hasStarted, + initialModel: closingTab.initialModel, closedAt: Date.now(), }; return [entry, ...prevClosed].slice(0, MAX_CLOSED_TABS); }); - // Drop the session's composer slice so the store doesn't - // accumulate stale entries over long-running workspaces. Restore - // creates a fresh slice from session data if the user brings this - // tab back. - sessionComposerActions.discard(closingTab.data.sessionId); + sessionComposerActions.discard(closingTab.sessionId); } if (tabId === activeMainTabId && newTabs.length > 0) { const targetIndex = currentIndex > 0 ? currentIndex - 1 : 0; setActiveMainTabId(newTabs[targetIndex].id); + setFocusActiveTabKey((prevKey) => prevKey + 1); } return newTabs; }); @@ -285,19 +285,9 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions try { const newSession = await createSessionMutation.mutateAsync(workspaceId); const agentHarness = initialModel ? getAgentHarnessForModel(initialModel) : "claude"; - const newId = `tab-${newSession.id}`; - const newTab: Tab = { - id: newId, - label: NEW_CHAT_LABEL, - data: { - sessionId: newSession.id, - agentHarness, - hasStarted: false, - initialModel, - }, - }; + const newTab = createPlaceholderSessionTab(newSession.id, agentHarness, initialModel); setMainTabs((prevTabs) => [...prevTabs, newTab]); - setActiveMainTabId(newId); + setActiveMainTabId(newTab.id); } catch (error) { console.error("[useChatTabs] Failed to create new session:", error); toast.error("Failed to create new chat session"); @@ -306,23 +296,22 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions [workspaceId, createSessionMutation] ); - const handleTabRestore = useCallback((closedTab: ClosedTab) => { + const handleTabRestore = useCallback((closedTab: ClosedSessionTab) => { const newId = `tab-${closedTab.sessionId}`; - const restoredTab: Tab = { + const restoredTab: SessionChatTab = { + kind: "session", id: newId, + sessionId: closedTab.sessionId, label: closedTab.label, - data: { - sessionId: closedTab.sessionId, - agentHarness: closedTab.agentHarness, - hasStarted: closedTab.label !== NEW_CHAT_LABEL, - initialModel: closedTab.initialModel, - }, + agentHarness: closedTab.agentHarness, + hasStarted: closedTab.hasStarted, + initialModel: closedTab.initialModel, }; setMainTabs((prev) => { // Don't add duplicate if session is already open in a tab - if (prev.some((t) => t.data?.sessionId === closedTab.sessionId)) { + if (prev.some((tab) => isSessionChatTab(tab) && tab.sessionId === closedTab.sessionId)) { return prev; } return [...prev, restoredTab]; @@ -337,19 +326,20 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions if (tabIndex === -1) return prevTabs; const tab = prevTabs[tabIndex]; - if (tab.data?.agentHarness === nextAgentHarness) return prevTabs; + if (tab.agentHarness === nextAgentHarness) return prevTabs; // Harness lock: once a session has messages, its harness is bound to a // specific SDK process (enforced server-side in handleSendMessage). // Ignore local harness changes on started tabs — otherwise the UI drifts // from the persisted session and the next send will be rejected anyway. - if (tab.data?.hasStarted) return prevTabs; + if (tab.hasStarted) return prevTabs; const updatedTabs = [...prevTabs]; updatedTabs[tabIndex] = { ...tab, label: NEW_CHAT_LABEL, - data: { ...tab.data, agentHarness: nextAgentHarness, hasStarted: false }, + agentHarness: nextAgentHarness, + hasStarted: false, }; return updatedTabs; }); @@ -361,27 +351,38 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions if (tabIndex === -1) return prevTabs; const tab = prevTabs[tabIndex]; - if (tab.data?.hasStarted) return prevTabs; + if (tab.hasStarted) return prevTabs; - const agentHarness = (tab.data?.agentHarness as AgentHarness | undefined) ?? "claude"; + const agentHarness = tab.agentHarness; const sequence = countStartedTabsOfHarness(prevTabs, agentHarness, tabId) + 1; const updatedTabs = [...prevTabs]; updatedTabs[tabIndex] = { ...tab, label: buildStartedChatLabel(agentHarness, sequence), - data: { ...tab.data, agentHarness, hasStarted: true }, + hasStarted: true, }; return updatedTabs; }); }, []); - const handleTabReorder = useCallback((reorderedTabs: Tab[]) => { + const handleTabReorder = useCallback((reorderedTabs: ChatTab[]) => { setMainTabs(reorderedTabs); }, []); // --- Keyboard shortcuts --- + function isTextFieldFocused(): boolean { + const activeElement = document.activeElement as HTMLElement | null; + return ( + !!activeElement && + (activeElement.tagName === "INPUT" || + activeElement.tagName === "TEXTAREA" || + activeElement.isContentEditable || + activeElement.getAttribute("role") === "textbox") + ); + } + useEffect(() => { function handleKeyDown(e: KeyboardEvent) { const isModKey = e.metaKey || e.ctrlKey; @@ -389,6 +390,7 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions // Cmd+Shift+T — restore last closed tab (check before Cmd+T) if (isModKey && e.shiftKey && key === "t") { + if (closedTabs.length === 0) return; e.preventDefault(); setClosedTabs((prev) => { if (prev.length === 0) return prev; @@ -401,22 +403,31 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions // Cmd+T — new chat tab if (isModKey && key === "t") { - const ae = document.activeElement as HTMLElement | null; - const isTextField = - !!ae && - (ae.tagName === "INPUT" || - ae.tagName === "TEXTAREA" || - ae.isContentEditable || - ae.getAttribute("role") === "textbox"); - if (isTextField) return; + if (isTextFieldFocused()) return; e.preventDefault(); handleTabAdd(); + return; + } + + // Cmd+W — close active chat tab when multiple tabs are open + if (isModKey && !e.shiftKey && key === "w") { + if (mainTabs.length <= 1 || !activeMainTabId) return; + if (isTextFieldFocused()) return; + e.preventDefault(); + handleTabClose(activeMainTabId); } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [handleTabAdd, handleTabRestore]); + }, [ + activeMainTabId, + closedTabs.length, + handleTabAdd, + handleTabClose, + handleTabRestore, + mainTabs.length, + ]); // --- Derived state --- @@ -427,6 +438,7 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions activeTabId: activeMainTabId, activeTab, closedTabs, + focusActiveTabKey, handleTabChange, handleTabClose, handleTabAdd, diff --git a/apps/web/src/features/session/index.ts b/apps/web/src/features/session/index.ts index 146b40f2f..0a16704ff 100644 --- a/apps/web/src/features/session/index.ts +++ b/apps/web/src/features/session/index.ts @@ -1,5 +1,4 @@ -export { SessionPanel, SystemPromptModal } from "./ui"; -export type { SessionPanelRef } from "./ui"; +export * from "./ui"; export * from "./api"; export type * from "./types"; export { SessionProvider, useSession } from "./context"; diff --git a/apps/web/src/features/session/ui/index.ts b/apps/web/src/features/session/ui/index.ts index f7ad6c5b6..130955c3e 100644 --- a/apps/web/src/features/session/ui/index.ts +++ b/apps/web/src/features/session/ui/index.ts @@ -1,3 +1,4 @@ export { SessionPanel } from "./SessionPanel"; export type { SessionPanelRef } from "./SessionPanel"; export { SystemPromptModal } from "./SystemPromptModal"; +export * from "./tabs"; diff --git a/apps/web/src/features/session/ui/tabs/ClosedSessionsPopover.tsx b/apps/web/src/features/session/ui/tabs/ClosedSessionsPopover.tsx new file mode 100644 index 000000000..954f74004 --- /dev/null +++ b/apps/web/src/features/session/ui/tabs/ClosedSessionsPopover.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { History } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { getAgentLogo } from "@/assets/agents"; +import { cn } from "@/shared/lib/utils"; +import type { ClosedSessionTab } from "./types"; + +const ICON_SIZE = "h-3.5 w-3.5"; + +interface ClosedSessionsPopoverProps { + closedTabs: ClosedSessionTab[]; + onTabRestore: (closedTab: ClosedSessionTab) => void; +} + +function getClosedTabIcon(agentHarness: ClosedSessionTab["agentHarness"]) { + const LogoComponent = getAgentLogo(agentHarness); + if (!LogoComponent) return null; + return ; +} + +export function ClosedSessionsPopover({ closedTabs, onTabRestore }: ClosedSessionsPopoverProps) { + const [open, setOpen] = useState(false); + + if (closedTabs.length === 0) return null; + + return ( + + + + + + + + {!open && ( + +

Restore closed session (⌘⇧T)

+
+ )} +
+ + +

Recently closed

+
+ {closedTabs.map((closedTab) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/src/features/session/ui/tabs/SessionTab.tsx b/apps/web/src/features/session/ui/tabs/SessionTab.tsx new file mode 100644 index 000000000..e867cb4ef --- /dev/null +++ b/apps/web/src/features/session/ui/tabs/SessionTab.tsx @@ -0,0 +1,112 @@ +import type { KeyboardEvent, RefCallback } from "react"; +import { X } from "lucide-react"; +import { getAgentLogo } from "@/assets/agents"; +import { cn } from "@/shared/lib/utils"; +import { CircularPixelGrid } from "../CircularPixelGrid"; +import type { ChatTab } from "./types"; + +const ICON_CROSS_FADE = + "transition-[opacity,filter,scale] duration-200 ease-[cubic-bezier(0.2,0,0,1)]"; +const AGENT_ICON_SIZE = "h-3.5 w-3.5"; + +interface SessionTabProps { + tab: ChatTab; + isActive: boolean; + isWorking: boolean; + isUnread: boolean; + canClose: boolean; + onSelect: () => void; + onClose?: () => void; + onKeyDown: (event: KeyboardEvent) => void; + tabRef?: RefCallback; +} + +function renderStatusIcon(tab: ChatTab, isWorking: boolean, isUnread: boolean) { + if (isWorking) { + return ; + } + + if (isUnread) { + return ; + } + + const LogoComponent = getAgentLogo(tab.agentHarness); + if (!LogoComponent) return null; + return ; +} + +export function SessionTab({ + tab, + isActive, + isWorking, + isUnread, + canClose, + onSelect, + onClose, + onKeyDown, + tabRef, +}: SessionTabProps) { + return ( +
+ {canClose && onClose ? ( + + ) : ( + + {renderStatusIcon(tab, isWorking, isUnread)} + + )} + + +
+ ); +} diff --git a/apps/web/src/features/session/ui/tabs/SessionTabBar.tsx b/apps/web/src/features/session/ui/tabs/SessionTabBar.tsx new file mode 100644 index 000000000..ea248f5a8 --- /dev/null +++ b/apps/web/src/features/session/ui/tabs/SessionTabBar.tsx @@ -0,0 +1,232 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { KeyboardEvent as ReactKeyboardEvent } from "react"; +import { PanelLeftClose, Plus } from "lucide-react"; +import { + DndContext, + MouseSensor, + TouchSensor, + closestCenter, + type DragEndEvent, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable"; +import { Tooltip, TooltipContent, TooltipKbd, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/shared/lib/utils"; +import { ClosedSessionsPopover } from "./ClosedSessionsPopover"; +import { SessionTab } from "./SessionTab"; +import { SortableSessionTab } from "./SortableSessionTab"; +import type { ChatTab, ClosedSessionTab } from "./types"; +import { getChatTabSessionId } from "./types"; + +const EMPTY_CLOSED_TABS: ClosedSessionTab[] = []; +const EMPTY_WORKING_SET = new Set(); + +interface SessionTabBarProps { + tabs: ChatTab[]; + activeTabId: string; + workingSessionIds?: Set; + unreadSessionIds?: Set; + focusActiveTabKey?: number; + onTabChange: (tabId: string) => void; + onTabClose?: (tabId: string) => void; + onTabAdd?: () => void; + onTabReorder?: (reorderedTabs: ChatTab[]) => void; + closedTabs?: ClosedSessionTab[]; + onTabRestore?: (closedTab: ClosedSessionTab) => void; + onCollapseChatPanel?: () => void; +} + +function getWrappedIndex(currentIndex: number, nextIndex: number, count: number): number { + if (count === 0) return currentIndex; + if (nextIndex < 0) return count - 1; + if (nextIndex >= count) return 0; + return nextIndex; +} + +export function SessionTabBar({ + tabs, + activeTabId, + workingSessionIds = EMPTY_WORKING_SET, + unreadSessionIds = EMPTY_WORKING_SET, + focusActiveTabKey = 0, + onTabChange, + onTabClose, + onTabAdd, + onTabReorder, + closedTabs = EMPTY_CLOSED_TABS, + onTabRestore, + onCollapseChatPanel, +}: SessionTabBarProps) { + const canCloseTabs = tabs.length > 1; + const tabRefs = useRef(new Map()); + const prevFocusKeyRef = useRef(focusActiveTabKey); + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }) + ); + + const tabIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]); + + const setTabRef = useCallback((tabId: string, node: HTMLButtonElement | null) => { + if (node) { + tabRefs.current.set(tabId, node); + return; + } + tabRefs.current.delete(tabId); + }, []); + + useEffect(() => { + if (focusActiveTabKey === prevFocusKeyRef.current) return; + prevFocusKeyRef.current = focusActiveTabKey; + tabRefs.current.get(activeTabId)?.focus(); + }, [activeTabId, focusActiveTabKey]); + + const focusAndSelectTabAtIndex = useCallback( + (index: number) => { + const nextTab = tabs[index]; + if (!nextTab) return; + onTabChange(nextTab.id); + tabRefs.current.get(nextTab.id)?.focus(); + }, + [tabs, onTabChange] + ); + + const handleTabKeyDown = useCallback( + (event: ReactKeyboardEvent, tabId: string) => { + const currentIndex = tabIds.indexOf(tabId); + if (currentIndex === -1) return; + + if (event.key === "ArrowLeft") { + event.preventDefault(); + focusAndSelectTabAtIndex(getWrappedIndex(currentIndex, currentIndex - 1, tabs.length)); + return; + } + + if (event.key === "ArrowRight") { + event.preventDefault(); + focusAndSelectTabAtIndex(getWrappedIndex(currentIndex, currentIndex + 1, tabs.length)); + return; + } + + if (event.key === "Home") { + event.preventDefault(); + focusAndSelectTabAtIndex(0); + return; + } + + if (event.key === "End") { + event.preventDefault(); + focusAndSelectTabAtIndex(tabs.length - 1); + return; + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onTabChange(tabId); + } + }, + [focusAndSelectTabAtIndex, onTabChange, tabIds, tabs.length] + ); + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id || !onTabReorder) return; + + const oldIndex = tabs.findIndex((tab) => tab.id === active.id); + const newIndex = tabs.findIndex((tab) => tab.id === over.id); + if (oldIndex === -1 || newIndex === -1) return; + + onTabReorder(arrayMove(tabs, oldIndex, newIndex)); + } + + return ( +
+
+ + + {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const sessionId = getChatTabSessionId(tab); + const isWorking = !!sessionId && workingSessionIds.has(sessionId); + const isUnread = + !isActive && !isWorking && !!sessionId && unreadSessionIds.has(sessionId); + + return ( + + onTabChange(tab.id)} + onClose={onTabClose ? () => onTabClose(tab.id) : undefined} + onKeyDown={(event) => handleTabKeyDown(event, tab.id)} + tabRef={(node) => setTabRef(tab.id, node)} + /> + + ); + })} + + + + {onTabAdd && ( + + + + + +
+ New chat + ⌘T +
+
+
+ )} +
+ + {onTabRestore && ( + + )} + + {onCollapseChatPanel && ( + + + + + +
+ Collapse chat + ⌘\ +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/features/session/ui/tabs/SortableSessionTab.tsx b/apps/web/src/features/session/ui/tabs/SortableSessionTab.tsx new file mode 100644 index 000000000..699b0750f --- /dev/null +++ b/apps/web/src/features/session/ui/tabs/SortableSessionTab.tsx @@ -0,0 +1,39 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { cn } from "@/shared/lib/utils"; + +interface SortableSessionTabProps { + id: string; + children: React.ReactNode; + className?: string; +} + +/** + * Sortable wrapper for session tabs. + * The entire tab is the drag activator, matching the old interaction model. + */ +export function SortableSessionTab({ id, children, className }: SortableSessionTabProps) { + const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + transition: { + duration: 200, + easing: "cubic-bezier(.165, .84, .44, 1)", + }, + }); + + const style = { + transform: CSS.Translate.toString(transform ? { ...transform, y: 0 } : null), + transition, + }; + + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/src/features/session/ui/tabs/index.ts b/apps/web/src/features/session/ui/tabs/index.ts new file mode 100644 index 000000000..4b8bde4a1 --- /dev/null +++ b/apps/web/src/features/session/ui/tabs/index.ts @@ -0,0 +1,3 @@ +export { SessionTabBar } from "./SessionTabBar"; +export type { ChatTab, ClosedSessionTab, PendingChatTab, SessionChatTab } from "./types"; +export { getChatTabSessionId, isSessionChatTab } from "./types"; diff --git a/apps/web/src/features/session/ui/tabs/types.ts b/apps/web/src/features/session/ui/tabs/types.ts new file mode 100644 index 000000000..420717fb2 --- /dev/null +++ b/apps/web/src/features/session/ui/tabs/types.ts @@ -0,0 +1,37 @@ +import type { AgentHarness } from "@/shared/agents"; + +interface BaseChatTab { + id: string; + label: string; + agentHarness: AgentHarness; + hasStarted: boolean; + initialModel?: string; +} + +export interface SessionChatTab extends BaseChatTab { + kind: "session"; + sessionId: string; +} + +export interface PendingChatTab extends BaseChatTab { + kind: "pending"; +} + +export type ChatTab = SessionChatTab | PendingChatTab; + +export interface ClosedSessionTab { + label: string; + sessionId: string; + agentHarness: AgentHarness; + hasStarted: boolean; + initialModel?: string; + closedAt: number; +} + +export function isSessionChatTab(tab: ChatTab | null | undefined): tab is SessionChatTab { + return !!tab && tab.kind === "session"; +} + +export function getChatTabSessionId(tab: ChatTab | null | undefined): string | null { + return isSessionChatTab(tab) ? tab.sessionId : null; +} diff --git a/apps/web/src/features/workspace/ui/MainContentTabs.tsx b/apps/web/src/features/workspace/ui/MainContentTabs.tsx deleted file mode 100644 index 901704308..000000000 --- a/apps/web/src/features/workspace/ui/MainContentTabs.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import { useState } from "react"; -import { X, Plus, History, PanelLeftClose } from "lucide-react"; -import { CircularPixelGrid } from "@/features/session/ui/CircularPixelGrid"; -import { - DndContext, - closestCenter, - MouseSensor, - TouchSensor, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { arrayMove, SortableContext, horizontalListSortingStrategy } from "@dnd-kit/sortable"; -import { Tooltip, TooltipContent, TooltipTrigger, TooltipKbd } from "@/components/ui/tooltip"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { cn } from "@/shared/lib/utils"; -import { getAgentLogo } from "@/assets/agents"; -import { SortableTab } from "./SortableTab"; - -/** - * Tab data structure — chat sessions only. - */ -export interface Tab { - id: string; - label: string; - data?: { - sessionId?: string; - agentHarness?: string; - hasStarted?: boolean; - /** Pre-selected model when tab is created from locked-group picker */ - initialModel?: string; - }; -} - -/** Info preserved when a chat tab is closed, for restore */ -export interface ClosedTab { - label: string; - sessionId: string; - agentHarness?: string; - /** Preserved so restore can re-create a tab with the same model selection */ - initialModel?: string; - closedAt: number; -} - -interface MainContentTabBarProps { - tabs: Tab[]; - activeTabId: string; - /** Set of session IDs currently in "working" status — per-tab spinners */ - workingSessionIds?: Set; - /** Set of session IDs with unseen activity — per-tab unread dots */ - unreadSessionIds?: Set; - onTabChange: (tabId: string) => void; - onTabClose?: (tabId: string) => void; - onTabAdd?: () => void; - onTabReorder?: (reorderedTabs: Tab[]) => void; - closedTabs?: ClosedTab[]; - onTabRestore?: (closedTab: ClosedTab) => void; - onCollapseChatPanel?: () => void; -} - -const ICON_SIZE = "w-3.5 h-3.5"; -const EMPTY_CLOSED_TABS: ClosedTab[] = []; - -// Icon cross-fade curve — matches BrowserTabBar. Both icons stay in the DOM -// so enter + exit both animate without a motion dep; CSS transitions with -// this ease give the skill's scale 0.25→1, opacity 0→1, blur 4→0 motion. -const ICON_CROSS_FADE = - "transition-[opacity,filter,scale] duration-200 ease-[cubic-bezier(0.2,0,0,1)]"; - -/** Render the rest-state tab icon — spinner when working, gold dot when - * unread, agent logo otherwise. */ -function renderTabStatusIcon(tab: Tab, isWorking: boolean, isUnread: boolean) { - if (isWorking) { - return ; - } - if (isUnread) { - return ; - } - const LogoComponent = getAgentLogo(tab.data?.agentHarness || "claude"); - if (LogoComponent) { - return ; - } - return null; -} - -function getClosedTabIcon(agentHarness?: string) { - const LogoComponent = getAgentLogo(agentHarness || "claude"); - if (LogoComponent) { - return ; - } - return null; -} - -/** - * MainContentTabBar — tabs-only bar for the chat area. - * Workspace context (repo, branch, PR actions) moved to WorkspaceHeader. - * - * Close rules: - * - Any tab can be closed as long as at least one tab remains - * - The close button only appears on hover when there are 2+ tabs - */ -const EMPTY_WORKING_SET = new Set(); - -export function MainContentTabBar({ - tabs, - activeTabId, - workingSessionIds = EMPTY_WORKING_SET, - unreadSessionIds = EMPTY_WORKING_SET, - onTabChange, - onTabClose, - onTabAdd, - onTabReorder, - closedTabs = EMPTY_CLOSED_TABS, - onTabRestore, - onCollapseChatPanel, -}: MainContentTabBarProps) { - const [restoreOpen, setRestoreOpen] = useState(false); - const canCloseTabs = tabs.length > 1; - - // Mouse: 5px distance prevents accidental drags when clicking tabs - // Touch: 250ms long-press required before drag activates (allows normal scrolling) - const sensors = useSensors( - useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), - useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }) - ); - - function handleDragEnd(event: DragEndEvent) { - const { active, over } = event; - if (!over || active.id === over.id || !onTabReorder) return; - - const oldIndex = tabs.findIndex((t) => t.id === active.id); - const newIndex = tabs.findIndex((t) => t.id === over.id); - if (oldIndex === -1 || newIndex === -1) return; - - onTabReorder(arrayMove(tabs, oldIndex, newIndex)); - } - - return ( -
-
- - t.id)} strategy={horizontalListSortingStrategy}> - {tabs.map((tab) => { - const isActive = tab.id === activeTabId; - // Per-tab working status: each tab checks its own session ID - // against the working set (populated by useWorkingSessionIds). - const isWorking = !!tab.data?.sessionId && workingSessionIds.has(tab.data.sessionId); - // Per-tab unread status: show dot when session has unseen activity - const isUnread = - !isActive && - !isWorking && - !!tab.data?.sessionId && - unreadSessionIds.has(tab.data.sessionId); - - return ( - -
onTabChange(tab.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onTabChange(tab.id); - } - }} - className={cn( - "group relative flex items-center gap-1 overflow-hidden", - "h-7 max-w-[200px] min-w-[80px] rounded-lg pr-2 pl-1", - "cursor-pointer text-base font-normal", - "transition-colors duration-150", - isActive - ? "bg-bg-raised text-text-secondary" - : isUnread - ? "text-text-secondary" - : "text-text-muted hover:text-text-tertiary" - )} - > - {/* Left icon slot — status icon at rest, close X on tab - * hover. Same pattern as BrowserTabBar: both icons stay - * in the DOM, a CSS transition cross-fades them with - * scale + opacity + blur. Click the slot to close; - * click the label to select. */} - {onTabClose && canCloseTabs ? ( - - ) : ( - // Single-tab state (can't close): plain status icon, no button. - - {renderTabStatusIcon(tab, isWorking, isUnread)} - - )} - -
- {tab.label} -
-
-
- ); - })} -
-
- - {/* New tab button — stays adjacent to tabs */} - {onTabAdd && ( - - - - - -
- New chat - ⌘T -
-
-
- )} -
- - {/* History button — pinned far right, outside scrollable area */} - {onTabRestore && closedTabs.length > 0 && ( - - - - - - - - {!restoreOpen && ( - -

Restore closed session (⌘⇧T)

-
- )} -
- -

Recently closed

-
- {closedTabs.map((ct, i) => ( - - ))} -
-
-
- )} - - {/* Collapse chat panel — pinned to right edge of tab bar */} - {onCollapseChatPanel && ( - - - - - -
- Collapse chat - ⌘\ -
-
-
- )} -
- ); -} diff --git a/apps/web/src/features/workspace/ui/SortableTab.tsx b/apps/web/src/features/workspace/ui/SortableTab.tsx deleted file mode 100644 index ebfd56f9c..000000000 --- a/apps/web/src/features/workspace/ui/SortableTab.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { cn } from "@/shared/lib/utils"; - -interface SortableTabProps { - id: string; - children: React.ReactNode; - className?: string; -} - -/** - * Sortable wrapper for chat tabs. - * Mirrors DraggableRepository pattern but for horizontal tab reordering. - * The entire tab is the drag activator (no separate handle needed). - * Transform restricted to X-axis to prevent vertical wobble. - */ -export function SortableTab({ id, children, className }: SortableTabProps) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id, - // Snappy spring-like transition for neighbors shifting (ease-out-quart 200ms) - transition: { - duration: 200, - easing: "cubic-bezier(.165, .84, .44, 1)", - }, - }); - - // Use Translate (not Transform) to avoid scaleX/scaleY stretching, lock to X-axis - const style = { - transform: CSS.Translate.toString(transform ? { ...transform, y: 0 } : null), - transition, - }; - - return ( -
- {children} -
- ); -} diff --git a/apps/web/src/features/workspace/ui/index.ts b/apps/web/src/features/workspace/ui/index.ts index 62357e347..efdfd9c05 100644 --- a/apps/web/src/features/workspace/ui/index.ts +++ b/apps/web/src/features/workspace/ui/index.ts @@ -2,6 +2,5 @@ export { ChangesView } from "./ChangesView"; export { FilesView } from "./FilesView"; export { DiffViewer } from "./DiffViewer"; export { ChangesDiffViewer } from "./ChangesDiffViewer"; -export { MainContentTabBar } from "./MainContentTabs"; export { WorkspaceHeader } from "./WorkspaceHeader"; export { BranchSelector } from "./BranchSelector"; From cebaeb4c2f76a46ca52d1d16b67d02e02e354600 Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Mon, 27 Apr 2026 20:44:32 +0200 Subject: [PATCH 2/7] Address session tab close review --- apps/web/src/features/session/ui/tabs/SessionTab.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/session/ui/tabs/SessionTab.tsx b/apps/web/src/features/session/ui/tabs/SessionTab.tsx index e867cb4ef..99f2f710e 100644 --- a/apps/web/src/features/session/ui/tabs/SessionTab.tsx +++ b/apps/web/src/features/session/ui/tabs/SessionTab.tsx @@ -63,7 +63,11 @@ export function SessionTab({ type="button" tabIndex={-1} aria-label={`Close ${tab.label} tab`} - onClick={onClose} + onPointerDown={(event) => event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + onClose(); + }} className={cn( "relative flex h-full w-7 shrink-0 items-center justify-center rounded-l-lg border-none bg-transparent p-0", "transition-[background-color,scale] duration-150 ease-out", From 9928d75556d513c9abba50faf913b51e2aa21b35 Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Mon, 27 Apr 2026 21:56:15 +0200 Subject: [PATCH 3/7] Gate Apps tab behind experimental setting --- apps/backend/src/lib/schemas.ts | 1 + apps/web/src/app/layouts/content-tabs.ts | 3 +-- .../ui/sections/ExperimentalSection.tsx | 19 +++++++++++++++++++ shared/types/settings.ts | 1 + 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/lib/schemas.ts b/apps/backend/src/lib/schemas.ts index 37c018be7..7e60dc258 100644 --- a/apps/backend/src/lib/schemas.ts +++ b/apps/backend/src/lib/schemas.ts @@ -174,6 +174,7 @@ export const PreferencesFile = z experimental_simulator: z.boolean().optional(), experimental_browser: z.boolean().optional(), experimental_design: z.boolean().optional(), + experimental_apps: z.boolean().optional(), }) .passthrough(); diff --git a/apps/web/src/app/layouts/content-tabs.ts b/apps/web/src/app/layouts/content-tabs.ts index d53eab19b..228a8ff1e 100644 --- a/apps/web/src/app/layouts/content-tabs.ts +++ b/apps/web/src/app/layouts/content-tabs.ts @@ -49,8 +49,7 @@ export const CONTENT_TABS: ContentTabItem[] = [ capabilityGate: "nativeSimulator", visibilityKey: "experimental_simulator", }, - // AAP (agentic apps protocol) — always visible, no settings/capability gate. - { id: "apps", label: "Apps", icon: LayoutGrid }, + { id: "apps", label: "Apps", icon: LayoutGrid, visibilityKey: "experimental_apps" }, { id: "config", label: "Agent", icon: Bot }, ]; diff --git a/apps/web/src/features/settings/ui/sections/ExperimentalSection.tsx b/apps/web/src/features/settings/ui/sections/ExperimentalSection.tsx index a07d90061..7b6b881a4 100644 --- a/apps/web/src/features/settings/ui/sections/ExperimentalSection.tsx +++ b/apps/web/src/features/settings/ui/sections/ExperimentalSection.tsx @@ -51,6 +51,25 @@ export function ExperimentalSection({ settings, saveSetting }: SettingsSectionPr + {/* Apps */} +
+
+ +

+ Launch and manage agentic apps inside the workspace. +

+
+ saveSetting("experimental_apps", checked)} + /> +
+ + + {/* Design */}
diff --git a/shared/types/settings.ts b/shared/types/settings.ts index 2f63cf686..272e48f52 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -39,6 +39,7 @@ export interface Settings { experimental_simulator?: boolean; experimental_browser?: boolean; experimental_design?: boolean; + experimental_apps?: boolean; // Remote Access remote_access_enabled?: boolean; From 9611f00ddf59c798e8002cd246a8a795492e8498 Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Thu, 30 Apr 2026 14:54:25 +0200 Subject: [PATCH 4/7] Polish session tabs and content rail --- apps/backend/src/services/agent/commands.ts | 9 + apps/web/src/app/layouts/ContentTabBar.tsx | 196 ++++++++++++------ apps/web/src/app/layouts/useChatTabs.ts | 109 ++++++---- .../src/features/file-browser/api/useFiles.ts | 4 +- .../file-browser/lib/fileTreeTheme.ts | 24 ++- .../features/session/api/session.queries.ts | 5 +- .../features/session/api/session.service.ts | 11 +- .../session/hooks/useSessionComposer.ts | 20 +- .../session/ui/tabs/ClosedSessionsPopover.tsx | 2 +- .../web/src/features/session/ui/tabs/types.ts | 1 + .../web/src/features/workspace/hooks/index.ts | 1 + .../hooks/useWorkspaceIsMobileProject.ts | 54 +++++ 12 files changed, 306 insertions(+), 130 deletions(-) create mode 100644 apps/web/src/features/workspace/hooks/useWorkspaceIsMobileProject.ts diff --git a/apps/backend/src/services/agent/commands.ts b/apps/backend/src/services/agent/commands.ts index 9eb1bf731..ce785ff37 100644 --- a/apps/backend/src/services/agent/commands.ts +++ b/apps/backend/src/services/agent/commands.ts @@ -312,6 +312,15 @@ function handleSendMessage(params: QueryParams): CommandResult { ); } + // New sessions default to Claude at creation time because the user may pick + // the actual harness in the composer before the first send. Persist that + // first-send choice so follow-up turns route to the same agent process. + if (session && session.message_count === 0 && session.agent_harness !== agentHarness) { + db.prepare( + "UPDATE sessions SET agent_harness = ?, updated_at = datetime('now') WHERE id = ?" + ).run(agentHarness, sessionId); + } + // 1. Persist the user message const result = writeUserMessage(sessionId, content, model); if (!result.success) throw new Error(result.error); diff --git a/apps/web/src/app/layouts/ContentTabBar.tsx b/apps/web/src/app/layouts/ContentTabBar.tsx index 4cd5ce2df..0f1eb52bd 100644 --- a/apps/web/src/app/layouts/ContentTabBar.tsx +++ b/apps/web/src/app/layouts/ContentTabBar.tsx @@ -6,16 +6,24 @@ * Inactive tabs: icon only with tooltip on hover. * * Tab definitions and visibility logic live in content-tabs.ts. - * This component is pure presentation — it renders icons/pills and fires onTabChange. + * This component owns tab priority and rendering; tab definitions live in content-tabs.ts. */ +import { MoreHorizontal } from "lucide-react"; import { useMemo } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { cn } from "@/shared/lib/utils"; import { useSettings } from "@/features/settings/api/settings.queries"; import { useSimulatorStatusStore } from "@/features/simulator/store"; +import { useWorkspaceIsMobileProject } from "@/features/workspace/hooks"; import type { ContentTab } from "@/features/workspace/store"; -import { CONTENT_TABS, isTabVisible } from "./content-tabs"; +import { CONTENT_TABS, isTabVisible, type ContentTabItem } from "./content-tabs"; interface ContentTabBarProps { activeTab: ContentTab; @@ -23,6 +31,75 @@ interface ContentTabBarProps { workspaceId?: string | null; } +const ALWAYS_PRIMARY_TAB_IDS: ContentTab[] = ["changes", "files", "terminal", "browser"]; + +function splitTabs( + visibleItems: ContentTabItem[], + activeTab: ContentTab, + isMobileProject: boolean +): { primaryItems: ContentTabItem[]; overflowItems: ContentTabItem[] } { + const visibleIds = new Set(visibleItems.map((item) => item.id)); + const primaryIds = new Set(); + + for (const id of ALWAYS_PRIMARY_TAB_IDS) { + if (visibleIds.has(id)) primaryIds.add(id); + } + + if (isMobileProject && visibleIds.has("simulator")) primaryIds.add("simulator"); + + // Never hide the tab the user is currently looking at behind overflow. + if (visibleIds.has(activeTab)) primaryIds.add(activeTab); + + return { + primaryItems: visibleItems.filter((item) => primaryIds.has(item.id)), + overflowItems: visibleItems.filter((item) => !primaryIds.has(item.id)), + }; +} + +function ContentTabButton({ + item, + isActive, + showDot, + onClick, +}: { + item: ContentTabItem; + isActive: boolean; + showDot: boolean; + onClick: () => void; +}) { + const Icon = item.icon; + const button = ( + + ); + + return isActive ? ( + button + ) : ( + + {button} + +

{item.label}

+
+
+ ); +} + export function ContentTabBar({ activeTab, onTabChange, workspaceId }: ContentTabBarProps) { const settings = useSettings().data; const simPhase = useSimulatorStatusStore((s) => @@ -35,67 +112,64 @@ export function ContentTabBar({ activeTab, onTabChange, workspaceId }: ContentTa () => CONTENT_TABS.filter((item) => isTabVisible(item.id, settings)), [settings] ); + const simulatorVisible = visibleItems.some((item) => item.id === "simulator"); + const isMobileProject = useWorkspaceIsMobileProject(workspaceId, { enabled: simulatorVisible }); + + const { primaryItems, overflowItems } = useMemo( + () => splitTabs(visibleItems, activeTab, isMobileProject), + [activeTab, isMobileProject, visibleItems] + ); return ( -
- {visibleItems.map((item) => { - const Icon = item.icon; - const isActive = activeTab === item.id; - const showDot = item.id === "simulator" && simulatorActive; - - return isActive ? ( - - ) : ( - - - - - -

{item.label}

-
-
- ); - })} +
+
+ {primaryItems.map((item) => { + const isActive = activeTab === item.id; + const showDot = item.id === "simulator" && simulatorActive; + + return ( + onTabChange(item.id)} + /> + ); + })} +
+ + {overflowItems.length > 0 && ( + + + + + + {overflowItems.map((item) => { + const Icon = item.icon; + const showDot = item.id === "simulator" && simulatorActive; + + return ( + onTabChange(item.id)}> + + {item.label} + {showDot && } + + ); + })} + + + )}
); } diff --git a/apps/web/src/app/layouts/useChatTabs.ts b/apps/web/src/app/layouts/useChatTabs.ts index ec851bbba..51376993a 100644 --- a/apps/web/src/app/layouts/useChatTabs.ts +++ b/apps/web/src/app/layouts/useChatTabs.ts @@ -24,6 +24,18 @@ import { getChatTabSessionId, isSessionChatTab } from "@/features/session/ui/tab const NEW_CHAT_LABEL = "New chat"; const MAX_CLOSED_TABS = 20; +function createClosedTabId(sessionId: string): string { + return `${sessionId}-${Date.now()}-${crypto.randomUUID()}`; +} + +function getOpenSessionIds(tabs: ChatTab[]): Set { + const ids = new Set(); + for (const tab of tabs) { + if (isSessionChatTab(tab)) ids.add(tab.sessionId); + } + return ids; +} + function buildStartedChatLabel(agentHarness: AgentHarness, sequence: number): string { return `${getAgentLabel(agentHarness)} #${sequence}`; } @@ -247,37 +259,47 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions const handleTabClose = useCallback( (tabId: string) => { - setMainTabs((prev) => { - if (prev.length <= 1) return prev; - - const closingTab = prev.find((t) => t.id === tabId); - const currentIndex = prev.findIndex((t) => t.id === tabId); - const newTabs = prev.filter((t) => t.id !== tabId); - - if (closingTab && isSessionChatTab(closingTab)) { - setClosedTabs((prevClosed) => { - const entry: ClosedSessionTab = { - label: closingTab.label, - sessionId: closingTab.sessionId, - agentHarness: closingTab.agentHarness, - hasStarted: closingTab.hasStarted, - initialModel: closingTab.initialModel, - closedAt: Date.now(), - }; - return [entry, ...prevClosed].slice(0, MAX_CLOSED_TABS); - }); - sessionComposerActions.discard(closingTab.sessionId); - } - - if (tabId === activeMainTabId && newTabs.length > 0) { - const targetIndex = currentIndex > 0 ? currentIndex - 1 : 0; - setActiveMainTabId(newTabs[targetIndex].id); - setFocusActiveTabKey((prevKey) => prevKey + 1); - } - return newTabs; - }); + if (mainTabs.length <= 1) return; + + const closingTab = mainTabs.find((tab) => tab.id === tabId); + const currentIndex = mainTabs.findIndex((tab) => tab.id === tabId); + if (!closingTab || currentIndex === -1) return; + + const newTabs = mainTabs.filter((tab) => tab.id !== tabId); + const openSessionIds = getOpenSessionIds(newTabs); + + setMainTabs(newTabs); + + if (isSessionChatTab(closingTab)) { + const entry: ClosedSessionTab = { + id: createClosedTabId(closingTab.sessionId), + label: closingTab.label, + sessionId: closingTab.sessionId, + agentHarness: closingTab.agentHarness, + hasStarted: closingTab.hasStarted, + initialModel: closingTab.initialModel, + closedAt: Date.now(), + }; + setClosedTabs((prevClosed) => + [ + entry, + ...prevClosed.filter( + (closedTab) => + closedTab.sessionId !== closingTab.sessionId && + !openSessionIds.has(closedTab.sessionId) + ), + ].slice(0, MAX_CLOSED_TABS) + ); + sessionComposerActions.discard(closingTab.sessionId); + } + + if (tabId === activeMainTabId && newTabs.length > 0) { + const targetIndex = currentIndex > 0 ? currentIndex - 1 : 0; + setActiveMainTabId(newTabs[targetIndex].id); + setFocusActiveTabKey((prevKey) => prevKey + 1); + } }, - [activeMainTabId] + [activeMainTabId, mainTabs] ); const handleTabAdd = useCallback( @@ -370,6 +392,15 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions setMainTabs(reorderedTabs); }, []); + // --- Derived state --- + + const activeTab = mainTabs.find((t) => t.id === activeMainTabId); + const openSessionIds = useMemo(() => getOpenSessionIds(mainTabs), [mainTabs]); + const restorableClosedTabs = useMemo( + () => closedTabs.filter((closedTab) => !openSessionIds.has(closedTab.sessionId)), + [closedTabs, openSessionIds] + ); + // --- Keyboard shortcuts --- function isTextFieldFocused(): boolean { @@ -390,14 +421,10 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions // Cmd+Shift+T — restore last closed tab (check before Cmd+T) if (isModKey && e.shiftKey && key === "t") { - if (closedTabs.length === 0) return; + const latestClosedTab = restorableClosedTabs[0]; + if (!latestClosedTab) return; e.preventDefault(); - setClosedTabs((prev) => { - if (prev.length === 0) return prev; - const [latest, ...rest] = prev; - queueMicrotask(() => handleTabRestore(latest)); - return rest; - }); + handleTabRestore(latestClosedTab); return; } @@ -422,22 +449,18 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions return () => window.removeEventListener("keydown", handleKeyDown); }, [ activeMainTabId, - closedTabs.length, handleTabAdd, handleTabClose, handleTabRestore, mainTabs.length, + restorableClosedTabs, ]); - // --- Derived state --- - - const activeTab = mainTabs.find((t) => t.id === activeMainTabId); - return { tabs: mainTabs, activeTabId: activeMainTabId, activeTab, - closedTabs, + closedTabs: restorableClosedTabs, focusActiveTabKey, handleTabChange, handleTabClose, diff --git a/apps/web/src/features/file-browser/api/useFiles.ts b/apps/web/src/features/file-browser/api/useFiles.ts index 80b639e24..0c5720558 100644 --- a/apps/web/src/features/file-browser/api/useFiles.ts +++ b/apps/web/src/features/file-browser/api/useFiles.ts @@ -17,14 +17,14 @@ async function scanWorkspaceFiles(workspaceId: string): Promise workspaceId ? scanWorkspaceFiles(workspaceId) : Promise.resolve({ files: [], totalFiles: 0, totalSize: 0 }), - enabled: !!workspaceId, + enabled: !!workspaceId && (options?.enabled ?? true), staleTime: 30000, // 30s cache refetchOnWindowFocus: false, gcTime: 5 * 60 * 1000, // 5 minutes diff --git a/apps/web/src/features/file-browser/lib/fileTreeTheme.ts b/apps/web/src/features/file-browser/lib/fileTreeTheme.ts index d52ba13b1..42c23d6b3 100644 --- a/apps/web/src/features/file-browser/lib/fileTreeTheme.ts +++ b/apps/web/src/features/file-browser/lib/fileTreeTheme.ts @@ -16,20 +16,26 @@ import type { CSSProperties } from "react"; /** * Pierre reads theming from CSS custom properties on the host element. - * These four overrides make the tree inherit our dark/light theme: - * background stays default (transparent), foreground + borders use the - * app tokens, and selection uses a primary-tinted surface with the primary - * colour itself as the selected text. + * These overrides make the tree inherit our runtime dark/light theme through + * Pierre's shadow-DOM boundary. Use app CSS variables directly here; Tailwind + * `--color-*` theme tokens are compile-time aliases and can resolve poorly + * inside third-party shadow roots. */ export const fileTreeThemeStyles: CSSProperties = { display: "block", height: "100%", width: "100%", - ["--trees-fg-override" as never]: "var(--color-foreground)", - ["--trees-border-color-override" as never]: "var(--color-border)", - ["--trees-selected-bg-override" as never]: - "color-mix(in oklab, var(--color-primary) 14%, transparent)", - ["--trees-selected-fg-override" as never]: "var(--color-primary)", + backgroundColor: "var(--bg-elevated)", + colorScheme: "light dark", + ["--trees-bg-override" as never]: "var(--bg-elevated)", + ["--trees-bg-muted-override" as never]: "var(--bg-muted)", + ["--trees-fg-override" as never]: "var(--text-secondary)", + ["--trees-border-color-override" as never]: "var(--border-default)", + ["--trees-input-bg-override" as never]: "var(--bg-elevated)", + ["--trees-search-bg-override" as never]: "var(--bg-muted)", + ["--trees-search-fg-override" as never]: "var(--text-primary)", + ["--trees-selected-bg-override" as never]: "color-mix(in oklab, var(--primary) 14%, transparent)", + ["--trees-selected-fg-override" as never]: "var(--text-primary)", }; /** diff --git a/apps/web/src/features/session/api/session.queries.ts b/apps/web/src/features/session/api/session.queries.ts index b8e9bd0f3..229bd1e46 100644 --- a/apps/web/src/features/session/api/session.queries.ts +++ b/apps/web/src/features/session/api/session.queries.ts @@ -243,8 +243,9 @@ export function useSendMessage() { // No return value needed — WS q:delta handles message reconciliation. return; } catch { - // Gateway/web fallback: HTTP POST to backend - return SessionService.sendMessage(sessionId, content, model); + // Fallback through the legacy service wrapper; keep the same runtime + // routing fields so backend validation behaves identically. + return SessionService.sendMessage(sessionId, content, model, agentHarness); } }, diff --git a/apps/web/src/features/session/api/session.service.ts b/apps/web/src/features/session/api/session.service.ts index 716acfdeb..4b9b6a78a 100644 --- a/apps/web/src/features/session/api/session.service.ts +++ b/apps/web/src/features/session/api/session.service.ts @@ -8,6 +8,7 @@ import { sendRequest, sendMutate, sendCommand } from "@/platform/ws"; import type { Session, Message } from "../types"; +import type { AgentHarness } from "@/shared/agents"; /** Pagination params for cursor-based message fetching (seq-based) */ export interface MessagePaginationParams { @@ -49,11 +50,17 @@ export const SessionService = { /** * Send a message to a session */ - sendMessage: async (id: string, content: string, model?: string): Promise => { + sendMessage: async ( + id: string, + content: string, + model: string, + agentHarness: AgentHarness + ): Promise => { const result = await sendCommand("sendMessage", { sessionId: id, content, - ...(model ? { model } : {}), + model, + agentHarness, }); if (!result.accepted) throw new Error(result.error || "Failed to send message"); return result as unknown as Message; diff --git a/apps/web/src/features/session/hooks/useSessionComposer.ts b/apps/web/src/features/session/hooks/useSessionComposer.ts index 10adcb614..043efc007 100644 --- a/apps/web/src/features/session/hooks/useSessionComposer.ts +++ b/apps/web/src/features/session/hooks/useSessionComposer.ts @@ -10,7 +10,7 @@ * both the seed value and as the clamp target when switching models. */ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import type { ThinkingLevel } from "@/shared/agents"; import type { InspectedElement } from "../ui/InspectedElementCard"; import type { FileMention } from "../ui/FileMentionCard"; @@ -54,16 +54,16 @@ export function useSessionComposer( sessionId: string, { initialModel, defaultThinking }: UseSessionComposerOptions ): UseSessionComposerReturn { - // Seed synchronously on first hook call for this sessionId. Idempotent - // — subsequent renders/mounts are no-ops, preserving the user's staged - // content across focus-mode toggles and remounts. Done here (not in - // useEffect) so the selector below never has to return a fallback - // object — selectors that create a new object per call trip React's - // useSyncExternalStore "infinite loop" heuristic. - sessionComposerActions.seedIfAbsent(sessionId, emptyComposer(initialModel, defaultThinking)); + const fallbackComposer = useMemo( + () => emptyComposer(initialModel, defaultThinking), + [initialModel, defaultThinking] + ); + + useEffect(() => { + sessionComposerActions.seedIfAbsent(sessionId, fallbackComposer); + }, [sessionId, fallbackComposer]); - // seedIfAbsent above guarantees the slice exists by the time we read it. - const state = useSessionComposerStore((s) => s.composers[sessionId]) as ComposerState; + const state = useSessionComposerStore((s) => s.composers[sessionId] ?? fallbackComposer); // Bind sessionId into each action once per mount. defaultThinking goes // into setModel's clamp fallback — changes to it re-create the bag. diff --git a/apps/web/src/features/session/ui/tabs/ClosedSessionsPopover.tsx b/apps/web/src/features/session/ui/tabs/ClosedSessionsPopover.tsx index 954f74004..a73671100 100644 --- a/apps/web/src/features/session/ui/tabs/ClosedSessionsPopover.tsx +++ b/apps/web/src/features/session/ui/tabs/ClosedSessionsPopover.tsx @@ -55,7 +55,7 @@ export function ClosedSessionsPopover({ closedTabs, onTabRestore }: ClosedSessio
{closedTabs.map((closedTab) => (