Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fea9f2e2d |
@@ -573,8 +573,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";
|
||||
|
||||
@@ -13,7 +13,6 @@ const RAIL_TABS: readonly RightRailTab[] = [
|
||||
"changes",
|
||||
"subagents",
|
||||
"terminals",
|
||||
"todos",
|
||||
"browser",
|
||||
];
|
||||
|
||||
@@ -22,7 +21,7 @@ export interface SessionWorkspaceState {
|
||||
open?: boolean;
|
||||
/** User-chosen rail width (px) for this session. */
|
||||
widthPx?: number;
|
||||
/** The selected rail tab (Files / Changes / Agents / Shells / Tasks). */
|
||||
/** The selected rail tab (Files / Changes / Agents / Shells). */
|
||||
rightRailTab?: RightRailTab;
|
||||
/** Ordered list of open file tabs. */
|
||||
openFiles?: string[];
|
||||
|
||||
@@ -91,7 +91,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" />,
|
||||
}));
|
||||
|
||||
@@ -171,9 +171,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
|
||||
@@ -518,12 +515,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",
|
||||
@@ -2141,10 +2135,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 },
|
||||
@@ -2557,7 +2551,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.
|
||||
@@ -2932,25 +2926,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)", () => {
|
||||
@@ -3101,67 +3087,8 @@ describe("Mobile session menu", () => {
|
||||
expect(drawer).toHaveAttribute("data-flat-view", "true");
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@@ -91,7 +91,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";
|
||||
@@ -307,7 +306,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
|
||||
@@ -418,15 +416,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
|
||||
@@ -638,24 +632,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 · Changes · Agents · Shells · Tasks · Browser). Picking
|
||||
// display order (Files · Changes · 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", "changes", "subagents", "terminals", "todos", "browser"] as const).find(
|
||||
const next = (["files", "changes", "subagents", "terminals", "browser"] as const).find(
|
||||
(t) => railTabsAvailable[t],
|
||||
);
|
||||
if (next) setRightRailTab(next);
|
||||
@@ -819,7 +812,6 @@ export function AppShell() {
|
||||
setFilesPanelOpen(false);
|
||||
setSubagentsPanelOpen(false);
|
||||
setShellsPanelOpen(false);
|
||||
setTodosPanelOpen(false);
|
||||
setFilesPanelShowHidden(true);
|
||||
if (!conversationId) {
|
||||
// No session → no rail; false (not the open default) so rail-gated
|
||||
@@ -870,7 +862,7 @@ export function AppShell() {
|
||||
return false;
|
||||
});
|
||||
// A selected file must be visible in the rail. The Files and Changes tabs
|
||||
// both surface the inline viewer; the Agents/Todos/Terminals tabs don't, so
|
||||
// both surface the inline viewer; the Agents/Terminals tabs don't, so
|
||||
// pull the rail to Files unless it's already on a files scope.
|
||||
if (nextSelected && nextTab !== "files" && nextTab !== "changes") {
|
||||
nextTab = "files";
|
||||
@@ -942,13 +934,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
|
||||
@@ -1255,7 +1244,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);
|
||||
}
|
||||
|
||||
@@ -1275,7 +1263,6 @@ export function AppShell() {
|
||||
setFilesPanelOpen(false);
|
||||
setSubagentsPanelOpen(false);
|
||||
setShellsPanelOpen(false);
|
||||
setTodosPanelOpen(false);
|
||||
setRightPanelOpen(true);
|
||||
if (conversationId) writeSessionWorkspaceState(conversationId, { open: true });
|
||||
},
|
||||
@@ -1323,7 +1310,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);
|
||||
}
|
||||
|
||||
@@ -1336,7 +1322,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
|
||||
setFilesDrawerFlatView(flatView);
|
||||
setFilesPanelOpen(true);
|
||||
}
|
||||
@@ -1352,7 +1337,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);
|
||||
}
|
||||
|
||||
@@ -1366,23 +1350,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.
|
||||
@@ -1689,7 +1659,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
|
||||
@@ -1698,9 +1667,6 @@ export function AppShell() {
|
||||
showShellsTab:
|
||||
!hideTerminalsTab && (railTerminals.length > 0 || agentSupportsShells),
|
||||
terminalsLength: railTerminals.length,
|
||||
todosSupported,
|
||||
todosCompleted,
|
||||
todosTotal: todos.length,
|
||||
debugMode,
|
||||
changedCount,
|
||||
subagentsWorking,
|
||||
@@ -1709,7 +1675,6 @@ export function AppShell() {
|
||||
onOpenChanges: openChangesPanel,
|
||||
onOpenShells: openShellsPanel,
|
||||
onOpenSubagents: openSubagentsPanel,
|
||||
onOpenTodos: openTodosPanel,
|
||||
onOpenMainExecutionLog: openMainExecutionLog,
|
||||
}}
|
||||
/>
|
||||
@@ -1756,9 +1721,6 @@ export function AppShell() {
|
||||
terminalsLength={railTerminals.length}
|
||||
subagentsWorking={subagentsWorking}
|
||||
agentCount={agentCount}
|
||||
todosSupported={todosSupported}
|
||||
todosCompleted={todosCompleted}
|
||||
todosTotal={todos.length}
|
||||
rootSessionId={rootSessionId}
|
||||
selectedFilePath={selectedFilePath}
|
||||
openFiles={openFiles}
|
||||
@@ -1849,16 +1811,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">
|
||||
|
||||
@@ -36,13 +36,9 @@ const mobileMenu = {
|
||||
filesPanelOpen: false,
|
||||
subagentsPanelOpen: false,
|
||||
shellsPanelOpen: false,
|
||||
todosPanelOpen: false,
|
||||
hideTerminalsTab: false,
|
||||
showShellsTab: false,
|
||||
terminalsLength: 0,
|
||||
todosSupported: false,
|
||||
todosCompleted: 0,
|
||||
todosTotal: 0,
|
||||
debugMode: false,
|
||||
changedCount: 0,
|
||||
subagentsWorking: 0,
|
||||
@@ -51,7 +47,6 @@ const mobileMenu = {
|
||||
onOpenChanges: () => {},
|
||||
onOpenShells: () => {},
|
||||
onOpenSubagents: () => {},
|
||||
onOpenTodos: () => {},
|
||||
onOpenMainExecutionLog: () => {},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
GitCompareIcon,
|
||||
InfoIcon,
|
||||
ListIcon,
|
||||
ListTodoIcon,
|
||||
PanelLeftIcon,
|
||||
PanelRightCloseIcon,
|
||||
PanelRightIcon,
|
||||
@@ -53,20 +52,12 @@ interface MobileSessionMenuProps {
|
||||
subagentsPanelOpen: boolean;
|
||||
/** True while the mobile shells drawer is open. */
|
||||
shellsPanelOpen: boolean;
|
||||
/** True while the mobile tasks drawer is open. */
|
||||
todosPanelOpen: boolean;
|
||||
/** Hide the Shells entry (claude-native sub-agents only). */
|
||||
hideTerminalsTab: boolean;
|
||||
/** Whether the Shells entry is available. */
|
||||
showShellsTab: boolean;
|
||||
/** Number of open terminals (entry badge). */
|
||||
terminalsLength: number;
|
||||
/** Whether the session publishes a todo list (gates the Tasks entry). */
|
||||
todosSupported: boolean;
|
||||
/** Completed todo count (Tasks entry badge numerator). */
|
||||
todosCompleted: number;
|
||||
/** Total todo count (Tasks entry badge denominator + visibility). */
|
||||
todosTotal: number;
|
||||
/** Debug mode — surfaces the Logs entry. */
|
||||
debugMode: boolean;
|
||||
/** Changed-file count (Files entry badge). */
|
||||
@@ -86,8 +77,6 @@ interface MobileSessionMenuProps {
|
||||
onOpenShells: () => void;
|
||||
/** Open the mobile agents drawer. */
|
||||
onOpenSubagents: () => void;
|
||||
/** Open the mobile tasks drawer. */
|
||||
onOpenTodos: () => void;
|
||||
/** Open the main execution-log push panel. */
|
||||
onOpenMainExecutionLog: () => void;
|
||||
}
|
||||
@@ -151,7 +140,7 @@ interface ChatHeaderProps {
|
||||
showFilesPanel: boolean;
|
||||
/**
|
||||
* Whether the right workspace rail has at least one available tab
|
||||
* (files, terminals, sub-agents, or todos). Gates the desktop
|
||||
* (files, terminals, or sub-agents). Gates the desktop
|
||||
* collapse toggle — with no rail content the panel doesn't mount
|
||||
* (see AppShell), so a toggle would flip an invisible card.
|
||||
*/
|
||||
@@ -449,7 +438,6 @@ export function ChatHeader({
|
||||
!mobileMenu.filesPanelOpen &&
|
||||
!mobileMenu.subagentsPanelOpen &&
|
||||
!mobileMenu.shellsPanelOpen &&
|
||||
!mobileMenu.todosPanelOpen &&
|
||||
(hasRailContent || mobileMenu.debugMode) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -532,18 +520,6 @@ export function ChatHeader({
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{mobileMenu.todosSupported && mobileMenu.todosTotal > 0 && (
|
||||
<DropdownMenuItem
|
||||
onSelect={mobileMenu.onOpenTodos}
|
||||
className="gap-2.5 px-2.5 py-2 text-ui"
|
||||
>
|
||||
<ListTodoIcon className="size-4" />
|
||||
Tasks
|
||||
<span className={cn(TAB_BADGE_BASE, "ml-auto bg-muted text-muted-foreground")}>
|
||||
{mobileMenu.todosCompleted}/{mobileMenu.todosTotal}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{mobileMenu.debugMode && (
|
||||
<DropdownMenuItem
|
||||
onSelect={mobileMenu.onOpenMainExecutionLog}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// Tests for TodoPanel — the small presentational panel that mirrors Claude
|
||||
// Code's todo list from useChatStore.todos. We mock the store so the panel's
|
||||
// own rendering (empty state, per-status icon/strikethrough, activeForm
|
||||
// subtitle) is exercised in isolation.
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
// The store is a selector hook: useChatStore((s) => s.todos). Mock it to feed a
|
||||
// controllable todos array per test.
|
||||
const h = vi.hoisted(() => ({ todos: [] as TodoItem[] }));
|
||||
vi.mock("@/store/chatStore", () => ({
|
||||
useChatStore: (selector: (s: { todos: TodoItem[] }) => unknown) => selector({ todos: h.todos }),
|
||||
}));
|
||||
|
||||
import { TodoPanel } from "./TodoPanel";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
h.todos = [];
|
||||
});
|
||||
|
||||
describe("TodoPanel", () => {
|
||||
it("renders nothing when the todo list is empty", () => {
|
||||
// WHY: the panel must occupy no space for sessions with no todos — it
|
||||
// returns null, so the container has no DOM children.
|
||||
h.todos = [];
|
||||
const { container } = render(<TodoPanel />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders one list item per todo with its content", () => {
|
||||
// WHY: confirms the map over todos renders every item's content text.
|
||||
h.todos = [
|
||||
{ content: "Write tests", status: "pending", activeForm: "Writing tests" },
|
||||
{ content: "Ship it", status: "completed", activeForm: "Shipping it" },
|
||||
];
|
||||
render(<TodoPanel />);
|
||||
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ship it")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("strikes through and dims a completed todo", () => {
|
||||
// WHY: completed todos get line-through + opacity-50; a regression in the
|
||||
// status-conditional classes would leave them looking active.
|
||||
h.todos = [{ content: "Done thing", status: "completed", activeForm: "Doing thing" }];
|
||||
render(<TodoPanel />);
|
||||
const span = screen.getByText("Done thing");
|
||||
expect(span.className).toContain("line-through");
|
||||
expect(span.closest("li")?.className).toContain("opacity-50");
|
||||
});
|
||||
|
||||
it("shows the activeForm subtitle for an in_progress todo when it differs", () => {
|
||||
// WHY: an in-progress item surfaces its activeForm ("Doing X") under the
|
||||
// content — this is the live-status affordance.
|
||||
h.todos = [{ content: "Build feature", status: "in_progress", activeForm: "Building feature" }];
|
||||
render(<TodoPanel />);
|
||||
expect(screen.getByText("Build feature")).toBeInTheDocument();
|
||||
expect(screen.getByText("Building feature")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the activeForm subtitle when it equals the content", () => {
|
||||
// WHY: the guard `activeForm !== content` prevents a redundant duplicate
|
||||
// line; identical text must appear exactly once.
|
||||
h.todos = [{ content: "Same text", status: "in_progress", activeForm: "Same text" }];
|
||||
render(<TodoPanel />);
|
||||
expect(screen.getAllByText("Same text")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not show the activeForm subtitle for a non-in_progress todo", () => {
|
||||
// WHY: the subtitle is gated on in_progress; a pending todo with a distinct
|
||||
// activeForm must not render it.
|
||||
h.todos = [{ content: "Pending thing", status: "pending", activeForm: "Pending action" }];
|
||||
render(<TodoPanel />);
|
||||
expect(screen.queryByText("Pending action")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { CheckCircle2Icon, CircleIcon, CircleDotIcon } from "lucide-react";
|
||||
import { useChatStore } from "@/store/chatStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TodoItem {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
interface TodoPanelProps {
|
||||
frameless?: boolean;
|
||||
}
|
||||
|
||||
function TodoIcon({ status }: { status: TodoItem["status"] }) {
|
||||
if (status === "completed") {
|
||||
return <CheckCircle2Icon className="h-3 w-3 shrink-0 text-green-500" />;
|
||||
}
|
||||
if (status === "in_progress") {
|
||||
return <CircleDotIcon className="h-3 w-3 shrink-0 text-blue-500" />;
|
||||
}
|
||||
return <CircleIcon className="h-3 w-3 shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the active task list published by any harness.
|
||||
*
|
||||
* Reads from `useChatStore.todos`, populated by the session snapshot and
|
||||
* `session.todos` SSE updates. Renders nothing while the list is empty.
|
||||
*/
|
||||
export function TodoPanel({ frameless = false }: TodoPanelProps) {
|
||||
const todos = useChatStore((s) => s.todos);
|
||||
|
||||
if (todos.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 flex-col bg-card",
|
||||
!frameless && "border-t border-b border-border",
|
||||
)}
|
||||
>
|
||||
<ul className="overflow-y-auto px-2 py-2">
|
||||
{todos.map((todo, i) => (
|
||||
<li
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded px-1.5 py-1 text-sm",
|
||||
todo.status === "completed" && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<TodoIcon status={todo.status} />
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"block break-words leading-snug",
|
||||
todo.status === "completed" && "line-through",
|
||||
)}
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
{todo.status === "in_progress" &&
|
||||
todo.activeForm &&
|
||||
todo.activeForm !== todo.content && (
|
||||
<span className="block truncate italic text-muted-foreground">
|
||||
{todo.activeForm}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,9 +31,6 @@ vi.mock("./InlineTerminalsSection", () => ({
|
||||
vi.mock("./SubagentsPanel", () => ({
|
||||
SubagentsPanel: () => <div data-testid="subagents-stub" />,
|
||||
}));
|
||||
vi.mock("./TodoPanel", () => ({
|
||||
TodoPanel: () => <div data-testid="todos-stub" />,
|
||||
}));
|
||||
vi.mock("@/components/BrowserPane/BrowserPane", () => ({
|
||||
BrowserPane: ({ conversationId }: { conversationId: string }) => (
|
||||
<div data-testid="browser-pane-stub">{conversationId}</div>
|
||||
@@ -115,9 +112,6 @@ function renderWorkspace(
|
||||
terminalsLength={0}
|
||||
subagentsWorking={0}
|
||||
agentCount={1}
|
||||
todosSupported={false}
|
||||
todosCompleted={0}
|
||||
todosTotal={0}
|
||||
rootSessionId={null}
|
||||
selectedFilePath={overrides.selectedFilePath ?? null}
|
||||
openFiles={overrides.openFiles ?? []}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
FolderTreeIcon,
|
||||
FileDiffIcon,
|
||||
GlobeIcon,
|
||||
ListTodoIcon,
|
||||
Loader2Icon,
|
||||
MaximizeIcon,
|
||||
MinimizeIcon,
|
||||
@@ -40,7 +39,6 @@ import { FileViewer } from "./FileViewer";
|
||||
import type { ChangedSort } from "./FlatFileList";
|
||||
import { InlineTerminalsSection } from "./InlineTerminalsSection";
|
||||
import { SubagentsPanel } from "./SubagentsPanel";
|
||||
import { TodoPanel } from "./TodoPanel";
|
||||
import { useTerminalStatuses } from "./useTerminalStatuses";
|
||||
import { type RightRailTab, TAB_BADGE_BASE } from "./railTabs";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -262,7 +260,7 @@ function NewTabMenu({
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileTabsStrip — open file tabs rendered in the top rail tab strip, as peers
|
||||
// of the fixed Files/Terminals/Agents/Tasks tabs. Each tab is a cell with the
|
||||
// of the fixed Files/Terminals/Agents tabs. Each tab is a cell with the
|
||||
// file's basename and an "x" close button. Clicking the cell activates the
|
||||
// tab (opening its viewer); clicking the x closes it. No own scroll container
|
||||
// or flex-1: the parent strip's overflow-x-auto scrolls the whole row.
|
||||
@@ -544,12 +542,6 @@ interface WorkspacePanelProps {
|
||||
* badge denominator) — starts at 1 for a lone agent.
|
||||
*/
|
||||
agentCount: number;
|
||||
/** Whether the session publishes a todo list (gates the Tasks tab). */
|
||||
todosSupported: boolean;
|
||||
/** Number of completed todos (Tasks tab badge numerator). */
|
||||
todosCompleted: number;
|
||||
/** Total todo count (Tasks tab badge denominator + visibility gate). */
|
||||
todosTotal: number;
|
||||
/**
|
||||
* The "root" session id for the Agents tab — the active session's
|
||||
* parent when inside a child, else the active id. May be null while
|
||||
@@ -603,7 +595,7 @@ interface WorkspacePanelProps {
|
||||
* WorkspacePanel — the desktop right "Workspace" rail, rendered as a
|
||||
* floating card (bg-card, rounded, bordered, shadowed) sitting below the
|
||||
* full-width chat header band. Internally tabbed between Files, Changes,
|
||||
* Terminals, Agents and Tasks so each can claim the full rail height
|
||||
* Terminals and Agents so each can claim the full rail height
|
||||
* instead of competing for a vertically-split slot.
|
||||
*
|
||||
* Desktop-only (``hidden md:flex``): on mobile the rail's contents are
|
||||
@@ -628,9 +620,6 @@ export function WorkspacePanel({
|
||||
terminalsLength,
|
||||
subagentsWorking,
|
||||
agentCount,
|
||||
todosSupported,
|
||||
todosCompleted,
|
||||
todosTotal,
|
||||
rootSessionId,
|
||||
selectedFilePath,
|
||||
openFiles,
|
||||
@@ -702,7 +691,7 @@ export function WorkspacePanel({
|
||||
className="absolute inset-y-0 left-0 z-10 w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors"
|
||||
/>
|
||||
)}
|
||||
{/* Tab strip, in display order Files · Changes · Agents · Shells · Tasks.
|
||||
{/* Tab strip, in display order Files · Changes · Agents · Shells.
|
||||
Files (full folder tree) and Changes (changed-files-only list) are
|
||||
two peer tabs — same gate (an on-disk workspace), same FilesPanel,
|
||||
each pinned to one scope. Agents is always present (the Agents panel
|
||||
@@ -796,21 +785,6 @@ export function WorkspacePanel({
|
||||
</TabsTrigger>
|
||||
</WorkspaceTabTooltip>
|
||||
)}
|
||||
{todosSupported && todosTotal > 0 && (
|
||||
<WorkspaceTabTooltip label="Tasks">
|
||||
<TabsTrigger
|
||||
value="todos"
|
||||
aria-label={`Tasks ${todosCompleted} of ${todosTotal} completed`}
|
||||
className="size-6 shrink-0 p-0 hover:border-1 hover:border-muted rounded-md!"
|
||||
>
|
||||
<ListTodoIcon />
|
||||
<span className="sr-only">Tasks</span>
|
||||
<span className="sr-only">
|
||||
{todosCompleted}/{todosTotal}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</WorkspaceTabTooltip>
|
||||
)}
|
||||
{showBrowserTab && (
|
||||
<WorkspaceTabTooltip label="Browser">
|
||||
<TabsTrigger
|
||||
@@ -930,8 +904,6 @@ export function WorkspacePanel({
|
||||
<BrowserPane conversationId={conversationId} className="min-h-0 flex-1" />
|
||||
) : rightRailTab === "subagents" && rootSessionId ? (
|
||||
<SubagentsPanel conversationId={conversationId} rootSessionId={rootSessionId} />
|
||||
) : rightRailTab === "todos" && todosSupported ? (
|
||||
<TodoPanel frameless />
|
||||
) : rightRailTab === "terminals" && showShellsTab ? (
|
||||
<InlineTerminalsSection conversationId={conversationId} onExpand={openTerminalTab} />
|
||||
) : (
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
/** The selectable tabs in the right workspace rail, in display order. */
|
||||
export type RightRailTab = "files" | "changes" | "subagents" | "terminals" | "todos" | "browser";
|
||||
export type RightRailTab = "files" | "changes" | "subagents" | "terminals" | "browser";
|
||||
|
||||
/**
|
||||
* Count/status badge geometry. Fixed height with min-width == height keeps a
|
||||
|
||||
Reference in New Issue
Block a user