Skip to content
Open
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
4 changes: 2 additions & 2 deletions web/src/lib/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,8 +569,8 @@ export interface SessionAgentChangedEvent {
* Each todo item has:
* - `content`: the task description string
* - `status`: `"pending"` | `"in_progress"` | `"completed"`
* - `activeForm`: present-continuous form of the task (e.g. `"Running tests"`).
* Shown by the TodoPanel under in-progress items when distinct from `content`.
* - `activeForm`: present-continuous form of the task (e.g. `"Running tests"`),
* the present-continuous label for an in-progress item when distinct from `content`.
*/
export interface SessionTodosEvent {
type: "session_todos";
Expand Down
4 changes: 2 additions & 2 deletions web/src/lib/sessionWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@

import type { RightRailTab } from "@/shell/railTabs";

const RAIL_TABS: readonly RightRailTab[] = ["files", "subagents", "terminals", "todos", "browser"];
const RAIL_TABS: readonly RightRailTab[] = ["files", "subagents", "terminals", "browser"];

export interface SessionWorkspaceState {
/** Whether the rail was left open in this session. */
open?: boolean;
/** User-chosen rail width (px) for this session. */
widthPx?: number;
/** The selected rail tab (Files / Agents / Shells / Tasks). */
/** The selected rail tab (Files / Agents / Shells). */
rightRailTab?: RightRailTab;
/** Ordered list of open file tabs. */
openFiles?: string[];
Expand Down
1 change: 0 additions & 1 deletion web/src/shell/AppShell.subagent-nav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ vi.mock("./FileViewer", () => ({
vi.mock("./InlineTerminalsSection", () => ({
InlineTerminalsSection: () => <div data-testid="inline-terminals-section" />,
}));
vi.mock("./TodoPanel", () => ({ TodoPanel: () => <div data-testid="todo-panel" /> }));
vi.mock("./FilesPanelDrawer", () => ({
FilesPanelDrawer: () => <div data-testid="files-panel-drawer" />,
}));
Expand Down
83 changes: 5 additions & 78 deletions web/src/shell/AppShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,6 @@ vi.mock("@/components/blocks/TerminalView", () => ({
<div data-testid="terminal-view-stub">{terminalId}</div>
),
}));
vi.mock("./TodoPanel", () => ({
TodoPanel: () => <div data-testid="todo-panel" />,
}));
vi.mock("./FilesPanelDrawer", () => ({
FilesPanelDrawer: ({ open, flatView }: { open: boolean; flatView: boolean }) => (
<div
Expand Down Expand Up @@ -472,12 +469,9 @@ beforeEach(() => {
// choice carries across sessions. Clear it so a stored preference from one
// test can't change another test's default scope.
localStorage.clear();
// The Tasks tab/drawer gates on chatStore.todos; reset so a populated
// todo list from one test doesn't leak into the next.
// Reset terminal-first startup signals so one test's terminalPending /
// failed status can't leak into another's terminalStartingUp.
useChatStore.setState({
todos: [],
terminalPending: false,
sessionStatus: "idle",
status: "idle",
Expand Down Expand Up @@ -1804,10 +1798,10 @@ describe("Right workspace card visibility", () => {
});

it("keeps the card mounted with Agents as the only tab for a minimal agent", () => {
// A no-os_env agent (available: false) with no shells and no todos
// A no-os_env agent (available: false) with no shells
// still has the unconditional Agents tab (the panel lists at least
// the main agent), so the card mounts, the Agents tab is selected
// by the fallback, and Files/Shells/Tasks are absent. An unmounted
// by the fallback, and Files/Shells are absent. An unmounted
// card here means the always-visible Agents rule regressed.
useEnvironmentMock.mockReturnValue({
data: { available: false, root: null, home: null },
Expand Down Expand Up @@ -2220,7 +2214,7 @@ describe("AppShell URL sync — file param", () => {
});

it("restores the file viewer into the desktop rail on a ?file= reload", () => {
// Regression (E2E reload-persistence): the Subagents/Terminals/Todos
// Regression (E2E reload-persistence): the Subagents/Terminals
// panels are checked before the file viewer in the rail content
// precedence. A ?file= reload must pull the rail to Files so the inline
// viewer renders instead of another panel shadowing it.
Expand Down Expand Up @@ -2784,25 +2778,17 @@ describe("Mobile session menu", () => {
isLoading: false,
error: null,
});
useChatStore.setState({
todos: [
{ content: "do a thing", status: "completed", activeForm: "doing a thing" },
{ content: "do another", status: "pending", activeForm: "doing another" },
],
});

renderShell("/c/conv_native");
openSessionMenu();

// Mirror of the desktop rail's tab strip for a native-wrapper session:
// Files · Agents · Tasks. Shells is absent because the only terminal is
// Files · Agents. Shells is absent because the only terminal is
// the vendor pane (the pill's Terminal view — excluded from the shell
// inventory) and the mocked agent declares no terminals. An unexpected
// Shells entry means the vendor pane leaked into the inventory.
expect(screen.getByRole("menuitem", { name: /^Files$/i })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: /Shells/i })).toBeNull();
expect(screen.getByRole("menuitem", { name: /Agents/i })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: /Tasks/i })).toBeInTheDocument();
});

it("keeps the Terminals entry in terminal-first SDK sessions (no native wrapper)", () => {
Expand Down Expand Up @@ -2934,67 +2920,8 @@ describe("Mobile session menu", () => {
expect(drawer).toHaveAttribute("data-flat-view", "false");
});

it("opens the Tasks drawer for a claude-native session with todos", () => {
useEnvironmentMock.mockReturnValue({
data: { available: true, root: null },
isLoading: false,
} as unknown as ReturnType<typeof useWorkspaceEnvironment>);
mockConversations([
{
id: "conv_native",
permission_level: null,
labels: { "omnigent.wrapper": "claude-code-native-ui" },
},
]);
useChatStore.setState({
todos: [{ content: "build the thing", status: "in_progress", activeForm: "building" }],
});

renderShell("/c/conv_native");

expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "closed");
expect(screen.queryByTestId("todo-panel")).toBeNull();

openSessionMenu();
fireEvent.click(screen.getByRole("menuitem", { name: /Tasks/i }));

// Failure: openTodosPanel didn't set todosPanelOpen, or the Tasks entry
// was gated out despite isClaudeNative + a non-empty todo list.
expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "open");
expect(screen.getByTestId("todo-panel")).toBeInTheDocument();
});

it.each([
["codex-native", "conv_codex", "codex-native-ui"],
["pi-native", "conv_pi", "pi-native-ui"],
])("opens the Tasks drawer for a %s session with todos", (_harness, id, wrapper) => {
useEnvironmentMock.mockReturnValue({
data: { available: true, root: null },
isLoading: false,
} as unknown as ReturnType<typeof useWorkspaceEnvironment>);
mockConversations([
{
id,
permission_level: null,
labels: { "omnigent.wrapper": wrapper },
},
]);
useChatStore.setState({
todos: [{ content: "Locate CLI parser", status: "in_progress", activeForm: "Locating" }],
});

renderShell(`/c/${id}`);
expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "closed");

openSessionMenu();
fireEvent.click(screen.getByRole("menuitem", { name: /Tasks/i }));

expect(screen.getByTestId("todos-panel-drawer")).toHaveAttribute("data-state", "open");
expect(screen.getByTestId("todo-panel")).toBeInTheDocument();
});

it("keeps the FAB with only the Agents entry for a minimal agent", () => {
// available:false → no files; no shells, no todos, no debug. The
// available:false → no files; no shells, no debug. The
// Agents entry is unconditional (badge = 1, the main agent), so the
// FAB still renders with exactly that entry. A missing FAB means
// the always-visible Agents rule regressed on mobile.
Expand Down
62 changes: 7 additions & 55 deletions web/src/shell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ import {
type TerminalFirstContextValue,
} from "./TerminalFirstContext";
import { TerminalsPanel } from "./TerminalsPanel";
import { TodoPanel } from "./TodoPanel";
import { PermissionsModal } from "@/components/PermissionsModal";
import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
import { CommandPalette } from "./CommandPalette";
Expand Down Expand Up @@ -270,7 +269,6 @@ export function AppShell() {
// on a phone they open as full-screen overlays from the session-menu FAB.
const [subagentsPanelOpen, setSubagentsPanelOpen] = useState(false);
const [shellsPanelOpen, setShellsPanelOpen] = useState(false);
const [todosPanelOpen, setTodosPanelOpen] = useState(false);
// The right "Workspace" rail (WorkspacePanel) remembers its open/closed
// state per session. A brand-new session (no saved `open`) follows the
// Appearance "Workspace panel" default; reopening a session restores how
Expand Down Expand Up @@ -378,15 +376,11 @@ export function AppShell() {
const sessionLabels = { ...activeConv?.labels, ...activeSession?.labels };
const terminalFirst = sessionLabels["omnigent.ui"] === "terminal";
const isClaudeNative = sessionLabels["omnigent.wrapper"] === "claude-code-native-ui";
const todos = useChatStore((s) => s.todos);
// The session.todos contract is harness-agnostic; show Tasks when it has data.
const todosSupported = todos.length > 0;
// Native-CLI wrapper of either family. Keys harness behavior gates
// (composer slash commands, `/model`); terminal-first SDK sessions
// (embedded Omnigent REPL terminal) have NO wrapper label and must
// keep regular chat behavior. See TerminalFirstContext.tsx.
const isNativeWrapper = isNativeWrapperLabel(sessionLabels["omnigent.wrapper"]);
const todosCompleted = todos.filter((t) => t.status === "completed").length;
// Used for the header "Back to parent" link, which is hidden on
// top-level sessions. The Subagents tab itself is always visible —
// it lists the root's children plus a "main" entry, so the user
Expand Down Expand Up @@ -550,24 +544,23 @@ export function AppShell() {
// ``railTerminals`` starts empty while the agent loads, so native
// sessions don't flash the tab.
terminals: !hideTerminalsTab && railTerminals.length > 0,
todos: todosSupported && todos.length > 0,
}) as const,
[showFilesPanel, hideTerminalsTab, railTerminals.length, todosSupported, todos.length],
[showFilesPanel, hideTerminalsTab, railTerminals.length],
);
// Whether the rail has anything at all to show. When false the workspace
// card doesn't mount and the header hides its collapse toggle — a
// no-filesystem agent with no terminals/sub-agents/todos would otherwise
// no-filesystem agent with no terminals/sub-agents would otherwise
// render an empty white card with no way to dismiss it.
const hasRailContent = Object.values(railTabsAvailable).some(Boolean);
// Keep the selected tab valid. When the current tab disappears — files
// panel turns off, or the Shells tab hides (native wrapper / no shell
// and no shell access) — fall back to the first still-visible tab in
// display order (Files · Agents · Shells · Tasks · Browser). Picking the first
// display order (Files · Agents · Shells · Browser). Picking the first
// available (rather than ping-ponging between two effects) keeps this
// convergent even when several tabs vanish at once.
useEffect(() => {
if (railTabsAvailable[rightRailTab]) return;
const next = (["files", "subagents", "terminals", "todos", "browser"] as const).find(
const next = (["files", "subagents", "terminals", "browser"] as const).find(
(t) => railTabsAvailable[t],
);
if (next) setRightRailTab(next);
Expand Down Expand Up @@ -723,7 +716,6 @@ export function AppShell() {
setFilesPanelOpen(false);
setSubagentsPanelOpen(false);
setShellsPanelOpen(false);
setTodosPanelOpen(false);
setFilesPanelShowHidden(false);
if (!conversationId) {
// No session → no rail; false (not the open default) so rail-gated
Expand Down Expand Up @@ -791,7 +783,7 @@ export function AppShell() {
if (wasMaximized) restoreSidebarAfterMaximize();
return false;
});
// A selected file must be visible in the rail. The Agents/Todos/Terminals
// A selected file must be visible in the rail. The Agents/Terminals
// tabs don't render the inline viewer, so pull the rail to Files.
if (nextSelected && nextTab !== "files") {
nextTab = "files";
Expand Down Expand Up @@ -901,13 +893,10 @@ export function AppShell() {
setExecutionLogsKey(null); // close execution-logs panel
setFilesPanelOpen(false); // close files drawer so the viewer is unobscured
setSubagentsPanelOpen(false); // close mobile agents drawer
setTodosPanelOpen(false); // close mobile tasks drawer
// Pull the rail to the Files tab when parked on a tab where the viewer
// won't render (Terminals, Subagents, Todos). The Files tab surfaces the
// won't render (Terminals, Subagents). The Files tab surfaces the
// FileViewer inline, so leave it undisturbed.
setRightRailTab((prev) =>
prev === "terminals" || prev === "subagents" || prev === "todos" ? "files" : prev,
);
setRightRailTab((prev) => (prev === "terminals" || prev === "subagents" ? "files" : prev));
// Reveal the rail so the viewer is actually visible — a session the user
// collapsed (or one that started collapsed via the Appearance default)
// would otherwise route the file into an invisible panel. Persist
Expand Down Expand Up @@ -1116,7 +1105,6 @@ export function AppShell() {
setFilesPanelOpen(false); // close files drawer
setSubagentsPanelOpen(false); // close mobile agents drawer
setShellsPanelOpen(false); // close mobile shells drawer
setTodosPanelOpen(false); // close mobile tasks drawer
setPanelInitialKey(key);
}

Expand All @@ -1136,7 +1124,6 @@ export function AppShell() {
setFilesPanelOpen(false);
setSubagentsPanelOpen(false);
setShellsPanelOpen(false);
setTodosPanelOpen(false);
setRightPanelOpen(true);
if (conversationId) writeSessionWorkspaceState(conversationId, { open: true });
},
Expand Down Expand Up @@ -1184,7 +1171,6 @@ export function AppShell() {
setFilesPanelOpen(false); // close files drawer
setSubagentsPanelOpen(false); // close mobile agents drawer
setShellsPanelOpen(false); // close mobile shells drawer
setTodosPanelOpen(false); // close mobile tasks drawer
setExecutionLogsKey(key);
}

Expand All @@ -1198,7 +1184,6 @@ export function AppShell() {
setExecutionLogsKey(null); // close execution-logs panel
setSubagentsPanelOpen(false); // close mobile agents drawer
setShellsPanelOpen(false); // close mobile shells drawer
setTodosPanelOpen(false); // close mobile tasks drawer
setFilesPanelOpen(true);
}

Expand All @@ -1211,7 +1196,6 @@ export function AppShell() {
setExecutionLogsKey(null); // close execution-logs panel
setFilesPanelOpen(false); // close files drawer
setShellsPanelOpen(false); // close mobile shells drawer
setTodosPanelOpen(false); // close mobile tasks drawer
setSubagentsPanelOpen(true);
}

Expand All @@ -1225,23 +1209,9 @@ export function AppShell() {
setExecutionLogsKey(null); // close execution-logs panel
setFilesPanelOpen(false); // close files drawer
setSubagentsPanelOpen(false); // close mobile agents drawer
setTodosPanelOpen(false); // close mobile tasks drawer
setShellsPanelOpen(true);
}

// Mobile FAB → "Tasks" opens the todo list (the desktop rail's Tasks tab)
// as a full-screen drawer.
function openTodosPanel() {
setSelectedFilePath(null); // close file viewer
clearFileViewerUrl();
setPanelInitialKey(null); // close terminals panel
setExecutionLogsKey(null); // close execution-logs panel
setFilesPanelOpen(false); // close files drawer
setSubagentsPanelOpen(false); // close mobile agents drawer
setShellsPanelOpen(false); // close mobile shells drawer
setTodosPanelOpen(true);
}

function openMainExecutionLog() {
// Mobile FAB → "Execution logs" jumps straight to the main thread.
// Children are reachable via the panel's tab switcher.
Expand Down Expand Up @@ -1453,7 +1423,6 @@ export function AppShell() {
filesPanelOpen,
subagentsPanelOpen,
shellsPanelOpen,
todosPanelOpen,
hideTerminalsTab,
// Mobile: reachable when a shell exists OR the agent
// declares shell access (so the drawer's "+ New shell" row
Expand All @@ -1462,17 +1431,13 @@ export function AppShell() {
showShellsTab:
!hideTerminalsTab && (railTerminals.length > 0 || agentSupportsShells),
terminalsLength: railTerminals.length,
todosSupported,
todosCompleted,
todosTotal: todos.length,
debugMode,
changedCount,
subagentsWorking,
agentCount,
onOpenFiles: openFilesPanel,
onOpenShells: openShellsPanel,
onOpenSubagents: openSubagentsPanel,
onOpenTodos: openTodosPanel,
onOpenMainExecutionLog: openMainExecutionLog,
}}
/>
Expand Down Expand Up @@ -1519,9 +1484,6 @@ export function AppShell() {
terminalsLength={railTerminals.length}
subagentsWorking={subagentsWorking}
agentCount={agentCount}
todosSupported={todosSupported}
todosCompleted={todosCompleted}
todosTotal={todos.length}
rootSessionId={rootSessionId}
selectedFilePath={selectedFilePath}
openFiles={openFiles}
Expand Down Expand Up @@ -1615,16 +1577,6 @@ export function AppShell() {
/>
</MobilePanelDrawer>
)}
{conversationId && (
<MobilePanelDrawer
open={todosPanelOpen}
title="Tasks"
onClose={() => setTodosPanelOpen(false)}
testId="todos-panel-drawer"
>
<TodoPanel frameless />
</MobilePanelDrawer>
)}
{/* Mobile-only push panel — on desktop the viewer lives inside the inline aside. */}
{conversationId && selectedFilePath !== null && (
<div className="md:hidden">
Expand Down
Loading
Loading