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/backend/src/services/agent/commands.ts b/apps/backend/src/services/agent/commands.ts
index 9eb1bf731..a30dd176d 100644
--- a/apps/backend/src/services/agent/commands.ts
+++ b/apps/backend/src/services/agent/commands.ts
@@ -312,6 +312,30 @@ 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) {
+ const result = db
+ .prepare(
+ `
+ UPDATE sessions
+ SET agent_harness = ?, updated_at = datetime('now')
+ WHERE id = ? AND message_count = 0
+ `
+ )
+ .run(agentHarness, sessionId);
+
+ if (result.changes === 0) {
+ const current = getSessionRaw(db, sessionId);
+ if (current && current.agent_harness !== agentHarness) {
+ throw new Error(
+ `Cannot switch agent from ${current.agent_harness} to ${agentHarness} on a session with messages. Open a new chat tab instead.`
+ );
+ }
+ }
+ }
+
// 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/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/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/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/app/layouts/useChatTabs.ts b/apps/web/src/app/layouts/useChatTabs.ts
index 30467a60e..51376993a 100644
--- a/apps/web/src/app/layouts/useChatTabs.ts
+++ b/apps/web/src/app/layouts/useChatTabs.ts
@@ -12,38 +12,81 @@ 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 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}`;
}
-/** 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 +124,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 +143,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 +168,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 +188,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 +238,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,
@@ -243,41 +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);
-
- // Save closed tab for restore — preserve initialModel so restoring
- // an unopened tab keeps the user's model choice (required downstream).
- if (closingTab?.data?.sessionId) {
- setClosedTabs((prevClosed) => {
- const entry: ClosedTab = {
- label: closingTab.label,
- sessionId: closingTab.data!.sessionId!,
- agentHarness: closingTab.data?.agentHarness,
- initialModel: closingTab.data?.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);
- }
-
- if (tabId === activeMainTabId && newTabs.length > 0) {
- const targetIndex = currentIndex > 0 ? currentIndex - 1 : 0;
- setActiveMainTabId(newTabs[targetIndex].id);
- }
- 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(
@@ -285,19 +307,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 +318,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 +348,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 +373,47 @@ 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);
}, []);
+ // --- 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 {
+ 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,44 +421,47 @@ export function useChatTabs({ workspaceId, activeSessionId }: UseChatTabsOptions
// Cmd+Shift+T — restore last closed tab (check before Cmd+T)
if (isModKey && e.shiftKey && key === "t") {
+ 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;
}
// 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]);
-
- // --- Derived state ---
-
- const activeTab = mainTabs.find((t) => t.id === activeMainTabId);
+ }, [
+ activeMainTabId,
+ handleTabAdd,
+ handleTabClose,
+ handleTabRestore,
+ mainTabs.length,
+ restorableClosedTabs,
+ ]);
return {
tabs: mainTabs,
activeTabId: activeMainTabId,
activeTab,
- closedTabs,
+ closedTabs: restorableClosedTabs,
+ focusActiveTabKey,
handleTabChange,
handleTabClose,
handleTabAdd,
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..330fa5a64 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, emptyComposer(initialModel, defaultThinking));
+ }, [sessionId, initialModel, defaultThinking]);
- // 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/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..25bb7e723
--- /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 (Cmd/Ctrl+Shift+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..99f2f710e
--- /dev/null
+++ b/apps/web/src/features/session/ui/tabs/SessionTab.tsx
@@ -0,0 +1,116 @@
+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/workspace/ui/SortableTab.tsx b/apps/web/src/features/session/ui/tabs/SortableSessionTab.tsx
similarity index 59%
rename from apps/web/src/features/workspace/ui/SortableTab.tsx
rename to apps/web/src/features/session/ui/tabs/SortableSessionTab.tsx
index ebfd56f9c..7c9299c86 100644
--- a/apps/web/src/features/workspace/ui/SortableTab.tsx
+++ b/apps/web/src/features/session/ui/tabs/SortableSessionTab.tsx
@@ -2,29 +2,25 @@ import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/shared/lib/utils";
-interface SortableTabProps {
+interface SortableSessionTabProps {
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.
+ * Sortable wrapper for session tabs.
+ * The entire tab is the drag activator, matching the old interaction model.
*/
-export function SortableTab({ id, children, className }: SortableTabProps) {
+export function SortableSessionTab({ id, children, className }: SortableSessionTabProps) {
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,
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..6ccd85d4d
--- /dev/null
+++ b/apps/web/src/features/session/ui/tabs/types.ts
@@ -0,0 +1,38 @@
+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 {
+ id: string;
+ 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/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/apps/web/src/features/workspace/hooks/index.ts b/apps/web/src/features/workspace/hooks/index.ts
index 0d8de125f..5b2bcfd0f 100644
--- a/apps/web/src/features/workspace/hooks/index.ts
+++ b/apps/web/src/features/workspace/hooks/index.ts
@@ -5,3 +5,4 @@
export { useWorkspaceLayout } from "./useWorkspaceLayout";
export { useResizeHandle } from "./useResizeHandle";
+export { useWorkspaceIsMobileProject } from "./useWorkspaceIsMobileProject";
diff --git a/apps/web/src/features/workspace/hooks/useWorkspaceIsMobileProject.ts b/apps/web/src/features/workspace/hooks/useWorkspaceIsMobileProject.ts
new file mode 100644
index 000000000..3c94986f1
--- /dev/null
+++ b/apps/web/src/features/workspace/hooks/useWorkspaceIsMobileProject.ts
@@ -0,0 +1,54 @@
+import { useMemo } from "react";
+import { useFiles } from "@/features/file-browser/api/useFiles";
+
+interface ProjectFileNode {
+ name: string;
+ path: string;
+ type: "file" | "directory";
+ children?: ProjectFileNode[];
+}
+
+const MOBILE_DIRECTORY_MARKERS = new Set(["android", "ios"]);
+
+const MOBILE_FILE_MARKERS = new Set([
+ "podfile",
+ "app.config.js",
+ "app.config.ts",
+ "eas.json",
+ "expo-env.d.ts",
+ "metro.config.js",
+ "metro.config.ts",
+ "react-native.config.js",
+]);
+
+function hasMobileProjectMarker(files: ProjectFileNode[] | undefined): boolean {
+ for (const node of files ?? []) {
+ const name = node.name.toLowerCase();
+ const pathSegments = node.path.toLowerCase().split("/");
+
+ if (
+ MOBILE_FILE_MARKERS.has(name) ||
+ name.endsWith(".xcodeproj") ||
+ name.endsWith(".xcworkspace") ||
+ (node.type === "directory" && MOBILE_DIRECTORY_MARKERS.has(name)) ||
+ pathSegments.some((segment) => MOBILE_DIRECTORY_MARKERS.has(segment))
+ ) {
+ return true;
+ }
+
+ if (hasMobileProjectMarker(node.children)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+export function useWorkspaceIsMobileProject(
+ workspaceId: string | null | undefined,
+ options?: { enabled?: boolean }
+) {
+ const files = useFiles(workspaceId ?? null, { enabled: options?.enabled }).data?.files;
+
+ return useMemo(() => hasMobileProjectMarker(files), [files]);
+}
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/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";
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;