Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
24 changes: 24 additions & 0 deletions apps/backend/src/services/agent/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
);
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 1. Persist the user message
const result = writeUserMessage(sessionId, content, model);
if (!result.success) throw new Error(result.error);
Expand Down
25 changes: 16 additions & 9 deletions apps/web/src/app/layouts/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -38,6 +42,7 @@ export function ChatArea({
activeTabId,
activeTab,
closedTabs,
focusActiveTabKey,
handleTabChange,
handleTabClose,
handleTabAdd,
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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);
},
Expand All @@ -102,11 +108,12 @@ export function ChatArea({

return (
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
<MainContentTabBar
<SessionTabBar
tabs={tabs}
activeTabId={activeTabId}
workingSessionIds={workingSessionIds}
unreadSessionIds={unreadSessionIds}
focusActiveTabKey={focusActiveTabKey}
onTabChange={handleTabChangeWithRead}
onTabClose={handleTabClose}
onTabAdd={handleTabAdd}
Expand All @@ -129,7 +136,7 @@ export function ChatArea({
workspaceDefaultBranch={workspace.git_default_branch}
isFirstSession={workspace.latest_message_sent_at === null}
embedded={true}
initialModel={activeTab?.data?.initialModel}
initialModel={activeTab?.initialModel}
onAgentHarnessChange={(agentHarness) =>
activeTab && updateChatTabAgentHarness(activeTab.id, agentHarness)
}
Expand Down
196 changes: 135 additions & 61 deletions apps/web/src/app/layouts/ContentTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,100 @@
* 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;
onTabChange: (tab: ContentTab) => void;
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<ContentTab>();

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 = (
<button
type="button"
role="tab"
aria-label={item.label}
aria-selected={isActive}
onClick={onClick}
className={cn(
"relative flex h-7 items-center rounded-lg transition-colors duration-150",
isActive
? "bg-bg-raised text-text-secondary gap-1.5 px-3 text-sm font-medium"
: "text-text-muted hover:text-text-secondary hover:bg-bg-muted justify-center px-2"
)}
>
<Icon className={isActive ? "h-[13px] w-[13px]" : "h-3.5 w-3.5"} />
{isActive && <span>{item.label}</span>}
{showDot && <span className="bg-success absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full" />}
</button>
);

return isActive ? (
button
) : (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
<p className="text-xs">{item.label}</p>
</TooltipContent>
</Tooltip>
);
}

export function ContentTabBar({ activeTab, onTabChange, workspaceId }: ContentTabBarProps) {
const settings = useSettings().data;
const simPhase = useSimulatorStatusStore((s) =>
Expand All @@ -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 (
<div
data-slot="content-tab-bar"
className="flex items-center gap-1"
role="tablist"
aria-label="Content panel"
>
{visibleItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
const showDot = item.id === "simulator" && simulatorActive;

return isActive ? (
<button
key={item.id}
type="button"
role="tab"
aria-label={item.label}
aria-selected={true}
onClick={() => onTabChange(item.id)}
className={cn(
"bg-bg-raised text-text-secondary",
"relative flex h-7 items-center gap-1.5 rounded-lg px-3",
"text-sm font-medium",
"transition-colors duration-150"
)}
>
<Icon className="h-[13px] w-[13px]" />
<span>{item.label}</span>
{showDot && (
<span className="bg-success absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full" />
)}
</button>
) : (
<Tooltip key={item.id} delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
role="tab"
aria-label={item.label}
aria-selected={false}
onClick={() => onTabChange(item.id)}
className={cn(
"text-text-muted hover:text-text-secondary hover:bg-bg-muted",
"relative flex h-7 items-center justify-center rounded-lg px-2",
"transition-colors duration-150"
)}
>
<Icon className="h-3.5 w-3.5" />
{showDot && (
<span className="bg-success absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
<p className="text-xs">{item.label}</p>
</TooltipContent>
</Tooltip>
);
})}
<div data-slot="content-tab-bar" className="flex items-center gap-1">
<div className="flex items-center gap-1" role="tablist" aria-label="Content panel">
{primaryItems.map((item) => {
const isActive = activeTab === item.id;
const showDot = item.id === "simulator" && simulatorActive;

return (
<ContentTabButton
key={item.id}
item={item}
isActive={isActive}
showDot={Boolean(showDot)}
onClick={() => onTabChange(item.id)}
/>
);
})}
</div>

{overflowItems.length > 0 && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="More content tabs"
className={cn(
"text-text-muted hover:text-text-secondary hover:bg-bg-muted",
"flex h-7 items-center justify-center rounded-lg px-2",
"transition-colors duration-150"
)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-40">
{overflowItems.map((item) => {
const Icon = item.icon;
const showDot = item.id === "simulator" && simulatorActive;

return (
<DropdownMenuItem key={item.id} onSelect={() => onTabChange(item.id)}>
<Icon className="h-3.5 w-3.5" />
<span>{item.label}</span>
{showDot && <span className="bg-success ml-auto h-2 w-2 rounded-full" />}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}
3 changes: 1 addition & 2 deletions apps/web/src/app/layouts/content-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
];

Expand Down
Loading
Loading