Skip to content
16 changes: 16 additions & 0 deletions .storybook/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import type {
} from "@/common/orpc/types";
import type { ChatStats } from "@/common/types/chatStats";
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
import {
DEFAULT_TASK_SETTINGS,
normalizeTaskSettings,
type TaskSettings,
} from "@/common/types/tasks";
import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";

/** Session usage data structure matching SessionUsageFileSchema */
Expand Down Expand Up @@ -46,6 +51,8 @@ export interface MockSessionUsage {
export interface MockORPCClientOptions {
projects?: Map<string, ProjectConfig>;
workspaces?: FrontendWorkspaceMetadata[];
/** Initial task settings for config.getConfig (e.g., Settings → Tasks section) */
taskSettings?: Partial<TaskSettings>;
/** Per-workspace chat callback. Return messages to emit, or use the callback for streaming. */
onChat?: (workspaceId: string, emit: (msg: WorkspaceChatMessage) => void) => (() => void) | void;
/** Mock for executeBash per workspace */
Expand Down Expand Up @@ -123,6 +130,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
mcpServers = new Map(),
mcpOverrides = new Map(),
mcpTestResults = new Map(),
taskSettings: initialTaskSettings,
} = options;

// Feature flags
Expand All @@ -140,6 +148,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
};

const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
let taskSettings = normalizeTaskSettings(initialTaskSettings ?? DEFAULT_TASK_SETTINGS);

const mockStats: ChatStats = {
consumers: [],
Expand Down Expand Up @@ -172,6 +181,13 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
getSshHost: async () => null,
setSshHost: async () => undefined,
},
config: {
getConfig: async () => ({ taskSettings }),
saveConfig: async (input: { taskSettings: unknown }) => {
taskSettings = normalizeTaskSettings(input.taskSettings);
return undefined;
},
},
providers: {
list: async () => providersList,
getConfig: async () => providersConfig,
Expand Down
3 changes: 3 additions & 0 deletions src/browser/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
partitionWorkspacesByAge,
formatDaysThreshold,
AGE_THRESHOLDS_DAYS,
computeWorkspaceDepthMap,
} from "@/browser/utils/ui/workspaceFiltering";
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
import SecretsModal from "./SecretsModal";
Expand Down Expand Up @@ -608,6 +609,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
{(() => {
const allWorkspaces =
sortedWorkspacesByProject.get(projectPath) ?? [];
const depthByWorkspaceId = computeWorkspaceDepthMap(allWorkspaces);
const { recent, buckets } = partitionWorkspacesByAge(
allWorkspaces,
workspaceRecency
Expand All @@ -625,6 +627,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
onSelectWorkspace={handleSelectWorkspace}
onRemoveWorkspace={handleRemoveWorkspace}
onToggleUnread={_onToggleUnread}
depth={depthByWorkspaceId[metadata.id] ?? 0}
/>
);

Expand Down
9 changes: 8 additions & 1 deletion src/browser/components/Settings/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React from "react";
import { Settings, Key, Cpu, X, Briefcase, FlaskConical } from "lucide-react";
import { Settings, Key, Cpu, X, Briefcase, FlaskConical, Bot } from "lucide-react";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog";
import { GeneralSection } from "./sections/GeneralSection";
import { TasksSection } from "./sections/TasksSection";
import { ProvidersSection } from "./sections/ProvidersSection";
import { ModelsSection } from "./sections/ModelsSection";
import { Button } from "@/browser/components/ui/button";
Expand All @@ -17,6 +18,12 @@ const SECTIONS: SettingsSection[] = [
icon: <Settings className="h-4 w-4" />,
component: GeneralSection,
},
{
id: "tasks",
label: "Tasks",
icon: <Bot className="h-4 w-4" />,
component: TasksSection,
},
{
id: "providers",
label: "Providers",
Expand Down
134 changes: 134 additions & 0 deletions src/browser/components/Settings/sections/TasksSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import React, { useEffect, useRef, useState } from "react";
import { useAPI } from "@/browser/contexts/API";
import { Input } from "@/browser/components/ui/input";
import {
DEFAULT_TASK_SETTINGS,
TASK_SETTINGS_LIMITS,
normalizeTaskSettings,
type TaskSettings,
} from "@/common/types/tasks";

export function TasksSection() {
const { api } = useAPI();
const [settings, setSettings] = useState<TaskSettings>(DEFAULT_TASK_SETTINGS);
const [loaded, setLoaded] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savingRef = useRef(false);

useEffect(() => {
if (!api) return;

setLoaded(false);
setLoadFailed(false);
setSaveError(null);

void api.config
.getConfig()
.then((cfg) => {
setSettings(normalizeTaskSettings(cfg.taskSettings));
setLoadFailed(false);
setLoaded(true);
})
.catch((error: unknown) => {
setSaveError(error instanceof Error ? error.message : String(error));
setLoadFailed(true);
setLoaded(true);
});
}, [api]);

useEffect(() => {
if (!api) return;
if (!loaded) return;
if (loadFailed) return;
if (savingRef.current) return;

if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}

saveTimerRef.current = setTimeout(() => {
savingRef.current = true;
void api.config
.saveConfig({ taskSettings: settings })
.catch((error: unknown) => {
setSaveError(error instanceof Error ? error.message : String(error));
})
.finally(() => {
savingRef.current = false;
});
}, 400);

return () => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [api, loaded, loadFailed, settings]);

const setMaxParallelAgentTasks = (rawValue: string) => {
const parsed = Number(rawValue);
setSettings((prev) => normalizeTaskSettings({ ...prev, maxParallelAgentTasks: parsed }));
};

const setMaxTaskNestingDepth = (rawValue: string) => {
const parsed = Number(rawValue);
setSettings((prev) => normalizeTaskSettings({ ...prev, maxTaskNestingDepth: parsed }));
};

return (
<div className="space-y-6">
<div>
<h3 className="text-foreground mb-4 text-sm font-medium">Tasks</h3>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="text-foreground text-sm">Max Parallel Agent Tasks</div>
<div className="text-muted text-xs">
Default {TASK_SETTINGS_LIMITS.maxParallelAgentTasks.default}, range{" "}
{TASK_SETTINGS_LIMITS.maxParallelAgentTasks.min}
{TASK_SETTINGS_LIMITS.maxParallelAgentTasks.max}
</div>
</div>
<Input
type="number"
value={settings.maxParallelAgentTasks}
min={TASK_SETTINGS_LIMITS.maxParallelAgentTasks.min}
max={TASK_SETTINGS_LIMITS.maxParallelAgentTasks.max}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMaxParallelAgentTasks(e.target.value)
}
className="border-border-medium bg-background-secondary h-9 w-28"
/>
</div>

<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="text-foreground text-sm">Max Task Nesting Depth</div>
<div className="text-muted text-xs">
Default {TASK_SETTINGS_LIMITS.maxTaskNestingDepth.default}, range{" "}
{TASK_SETTINGS_LIMITS.maxTaskNestingDepth.min}
{TASK_SETTINGS_LIMITS.maxTaskNestingDepth.max}
</div>
</div>
<Input
type="number"
value={settings.maxTaskNestingDepth}
min={TASK_SETTINGS_LIMITS.maxTaskNestingDepth.min}
max={TASK_SETTINGS_LIMITS.maxTaskNestingDepth.max}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMaxTaskNestingDepth(e.target.value)
}
className="border-border-medium bg-background-secondary h-9 w-28"
/>
</div>
</div>

{saveError && <div className="text-danger-light mt-4 text-xs">{saveError}</div>}
</div>
</div>
);
}
7 changes: 6 additions & 1 deletion src/browser/components/WorkspaceListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface WorkspaceListItemProps {
projectName: string;
isSelected: boolean;
isDeleting?: boolean;
depth?: number;
/** @deprecated No longer used since status dot was removed, kept for API compatibility */
lastReadTimestamp?: number;
// Event handlers
Expand All @@ -38,6 +39,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
projectName,
isSelected,
isDeleting,
depth,
lastReadTimestamp: _lastReadTimestamp,
onSelectWorkspace,
onRemoveWorkspace,
Expand Down Expand Up @@ -101,18 +103,21 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({

const { canInterrupt, awaitingUserQuestion } = useWorkspaceSidebarState(workspaceId);
const isWorking = canInterrupt && !awaitingUserQuestion;
const safeDepth = typeof depth === "number" && Number.isFinite(depth) ? Math.max(0, depth) : 0;
const paddingLeft = 9 + Math.min(32, safeDepth) * 12;

return (
<React.Fragment>
<div
className={cn(
"py-1.5 pl-[9px] pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
"py-1.5 pr-2 border-l-[3px] border-transparent transition-all duration-150 text-[13px] relative flex gap-2",
isDisabled
? "cursor-default opacity-70"
: "cursor-pointer hover:bg-hover [&:hover_button]:opacity-100",
isSelected && !isDisabled && "bg-hover border-l-blue-400",
isDeleting && "pointer-events-none"
)}
style={{ paddingLeft }}
onClick={() => {
if (isDisabled) return;
onSelectWorkspace({
Expand Down
46 changes: 45 additions & 1 deletion src/browser/stories/App.settings.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import { appMeta, AppWithMocks, type AppStory } from "./meta.js";
import { createWorkspace, groupWorkspacesByProject } from "./mockFactory";
import { selectWorkspace } from "./storyHelpers";
import { createMockORPCClient } from "../../../.storybook/mocks/orpc";
import { within, userEvent } from "@storybook/test";
import { within, userEvent, waitFor } from "@storybook/test";
import { getExperimentKey, EXPERIMENT_IDS } from "@/common/constants/experiments";
import type { TaskSettings } from "@/common/types/tasks";

export default {
...appMeta,
Expand All @@ -30,6 +31,7 @@ export default {
function setupSettingsStory(options: {
providersConfig?: Record<string, { apiKeySet: boolean; baseUrl?: string; models?: string[] }>;
providersList?: string[];
taskSettings?: Partial<TaskSettings>;
/** Pre-set experiment states in localStorage before render */
experiments?: Partial<Record<string, boolean>>;
}): APIClient {
Expand All @@ -50,6 +52,7 @@ function setupSettingsStory(options: {
workspaces,
providersConfig: options.providersConfig ?? {},
providersList: options.providersList ?? ["anthropic", "openai", "xai"],
taskSettings: options.taskSettings,
});
}

Expand Down Expand Up @@ -89,6 +92,47 @@ export const General: AppStory = {
},
};

/** Tasks settings section - task parallelism and nesting controls */
export const Tasks: AppStory = {
render: () => (
<AppWithMocks
setup={() =>
setupSettingsStory({
taskSettings: { maxParallelAgentTasks: 2, maxTaskNestingDepth: 4 },
})
}
/>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await openSettingsToSection(canvasElement, "tasks");

const body = within(canvasElement.ownerDocument.body);

await body.findByText(/Max Parallel Agent Tasks/i);
await body.findByText(/Max Task Nesting Depth/i);

const inputs = await body.findAllByRole("spinbutton");
if (inputs.length !== 2) {
throw new Error(`Expected 2 task settings inputs, got ${inputs.length}`);
}

await waitFor(() => {
const maxParallelAgentTasks = (inputs[0] as HTMLInputElement).value;
const maxTaskNestingDepth = (inputs[1] as HTMLInputElement).value;
if (maxParallelAgentTasks !== "2") {
throw new Error(
`Expected maxParallelAgentTasks=2, got ${JSON.stringify(maxParallelAgentTasks)}`
);
}
if (maxTaskNestingDepth !== "4") {
throw new Error(
`Expected maxTaskNestingDepth=4, got ${JSON.stringify(maxTaskNestingDepth)}`
);
}
});
},
};

/** Providers section - no providers configured */
export const ProvidersEmpty: AppStory = {
render: () => <AppWithMocks setup={() => setupSettingsStory({ providersConfig: {} })} />,
Expand Down
Loading