Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
9 changes: 9 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,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);
}
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