Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .storybook/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
tick: async function* () {
// No-op generator
},
getTaskSettings: async () => ({
maxParallelAgentTasks: 3,
maxTaskNestingDepth: 3,
}),
setTaskSettings: async () => undefined,
},
projects: {
list: async () => Array.from(projects.entries()),
Expand Down
4 changes: 2 additions & 2 deletions src/browser/components/LeftSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { cn } from "@/common/lib/utils";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { WorkspaceWithNesting } from "@/browser/utils/ui/workspaceFiltering";
import ProjectSidebar from "./ProjectSidebar";
import { TitleBar } from "./TitleBar";

Expand All @@ -9,7 +9,7 @@ interface LeftSidebarProps {
onToggleUnread: (workspaceId: string) => void;
collapsed: boolean;
onToggleCollapsed: () => void;
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
sortedWorkspacesByProject: Map<string, WorkspaceWithNesting[]>;
workspaceRecency: Record<string, number>;
}

Expand Down
7 changes: 4 additions & 3 deletions src/browser/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from "react";
import { cn } from "@/common/lib/utils";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import type { WorkspaceWithNesting } from "@/browser/utils/ui/workspaceFiltering";
import { usePersistedState } from "@/browser/hooks/usePersistedState";
import { EXPANDED_PROJECTS_KEY } from "@/common/constants/storage";
import { DndProvider } from "react-dnd";
Expand Down Expand Up @@ -179,7 +179,7 @@ interface ProjectSidebarProps {
onToggleUnread: (workspaceId: string) => void;
collapsed: boolean;
onToggleCollapsed: () => void;
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
sortedWorkspacesByProject: Map<string, WorkspaceWithNesting[]>;
workspaceRecency: Record<string, number>;
}

Expand Down Expand Up @@ -613,7 +613,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
workspaceRecency
);

const renderWorkspace = (metadata: FrontendWorkspaceMetadata) => (
const renderWorkspace = (metadata: WorkspaceWithNesting) => (
<WorkspaceListItem
key={metadata.id}
metadata={metadata}
Expand All @@ -622,6 +622,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
isSelected={selectedWorkspace?.workspaceId === metadata.id}
isDeleting={deletingWorkspaceIds.has(metadata.id)}
lastReadTimestamp={lastReadTimestamps[metadata.id] ?? 0}
nestingDepth={metadata.nestingDepth}
onSelectWorkspace={handleSelectWorkspace}
onRemoveWorkspace={handleRemoveWorkspace}
onToggleUnread={_onToggleUnread}
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,5 +1,5 @@
import React from "react";
import { Settings, Key, Cpu, X, Briefcase, FlaskConical } from "lucide-react";
import { Settings, Key, Cpu, X, Briefcase, FlaskConical, ListTodo } from "lucide-react";
import { useSettings } from "@/browser/contexts/SettingsContext";
import { Dialog, DialogContent, DialogTitle, VisuallyHidden } from "@/browser/components/ui/dialog";
import { GeneralSection } from "./sections/GeneralSection";
Expand All @@ -8,6 +8,7 @@ import { ModelsSection } from "./sections/ModelsSection";
import { Button } from "@/browser/components/ui/button";
import { ProjectSettingsSection } from "./sections/ProjectSettingsSection";
import { ExperimentsSection } from "./sections/ExperimentsSection";
import { TasksSection } from "./sections/TasksSection";
import type { SettingsSection } from "./types";

const SECTIONS: SettingsSection[] = [
Expand Down Expand Up @@ -35,6 +36,12 @@ const SECTIONS: SettingsSection[] = [
icon: <Cpu className="h-4 w-4" />,
component: ModelsSection,
},
{
id: "tasks",
label: "Tasks",
icon: <ListTodo className="h-4 w-4" />,
component: TasksSection,
},
{
id: "experiments",
label: "Experiments",
Expand Down
136 changes: 136 additions & 0 deletions src/browser/components/Settings/sections/TasksSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import React, { useEffect, useState, useCallback } from "react";
import { Input } from "@/browser/components/ui/input";
import { useAPI } from "@/browser/contexts/API";
import type { TaskSettings } from "@/common/types/task";

const DEFAULT_TASK_SETTINGS: TaskSettings = {
maxParallelAgentTasks: 3,
maxTaskNestingDepth: 3,
};

// Limits for task settings
const MIN_PARALLEL = 1;
const MAX_PARALLEL = 10;
const MIN_DEPTH = 1;
const MAX_DEPTH = 5;

export function TasksSection() {
const { api } = useAPI();
const [settings, setSettings] = useState<TaskSettings>(DEFAULT_TASK_SETTINGS);
const [loaded, setLoaded] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);

// Load settings on mount
useEffect(() => {
if (api) {
api.general
.getTaskSettings()
.then((taskSettings) => {
setSettings({
maxParallelAgentTasks:
taskSettings.maxParallelAgentTasks ?? DEFAULT_TASK_SETTINGS.maxParallelAgentTasks,
maxTaskNestingDepth:
taskSettings.maxTaskNestingDepth ?? DEFAULT_TASK_SETTINGS.maxTaskNestingDepth,
});
setLoaded(true);
})
.catch((error) => {
console.error("Failed to load task settings:", error);
setLoadError("Failed to load settings");
// Still mark as loaded so user can interact with defaults
setLoaded(true);
});
}
}, [api]);

const updateSetting = useCallback(
async (key: keyof TaskSettings, value: number) => {
const newSettings = { ...settings, [key]: value };
setSettings(newSettings);

// Persist to config
try {
await api?.general.setTaskSettings(newSettings);
} catch (error) {
console.error("Failed to save task settings:", error);
}
},
[api, settings]
);

const handleParallelChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value)) {
const clamped = Math.max(MIN_PARALLEL, Math.min(MAX_PARALLEL, value));
void updateSetting("maxParallelAgentTasks", clamped);
}
},
[updateSetting]
);

const handleDepthChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value)) {
const clamped = Math.max(MIN_DEPTH, Math.min(MAX_DEPTH, value));
void updateSetting("maxTaskNestingDepth", clamped);
}
},
[updateSetting]
);

if (!loaded) {
return <div className="text-muted text-sm">Loading task settings...</div>;
}

return (
<div className="space-y-6">
<div>
<h3 className="text-foreground mb-4 text-sm font-medium">Agent Tasks</h3>
<p className="text-muted mb-4 text-xs">
Configure limits for agent subworkspaces spawned via the task tool.
</p>
{loadError && (
<p className="mb-4 text-xs text-red-500">{loadError}. Using default values.</p>
)}

<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-foreground text-sm">Max Parallel Tasks</div>
<div className="text-muted text-xs">
Maximum agent tasks running at once ({MIN_PARALLEL}–{MAX_PARALLEL})
</div>
</div>
<Input
type="number"
min={MIN_PARALLEL}
max={MAX_PARALLEL}
value={settings.maxParallelAgentTasks}
onChange={handleParallelChange}
className="border-border-medium bg-background-secondary h-9 w-20 text-center"
/>
</div>

<div className="flex items-center justify-between">
<div>
<div className="text-foreground text-sm">Max Nesting Depth</div>
<div className="text-muted text-xs">
Maximum depth of nested agent tasks ({MIN_DEPTH}–{MAX_DEPTH})
</div>
</div>
<Input
type="number"
min={MIN_DEPTH}
max={MAX_DEPTH}
value={settings.maxTaskNestingDepth}
onChange={handleDepthChange}
className="border-border-medium bg-background-secondary h-9 w-20 text-center"
/>
</div>
</div>
</div>
</div>
);
}
18 changes: 16 additions & 2 deletions src/browser/components/WorkspaceListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface WorkspaceListItemProps {
isDeleting?: boolean;
/** @deprecated No longer used since status dot was removed, kept for API compatibility */
lastReadTimestamp?: number;
/** Nesting depth for agent task workspaces (0 = top-level, 1+ = nested) */
nestingDepth?: number;
// Event handlers
onSelectWorkspace: (selection: WorkspaceSelection) => void;
onRemoveWorkspace: (workspaceId: string, button: HTMLElement) => Promise<void>;
Expand All @@ -39,6 +41,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
isSelected,
isDeleting,
lastReadTimestamp: _lastReadTimestamp,
nestingDepth = 0,
onSelectWorkspace,
onRemoveWorkspace,
onToggleUnread: _onToggleUnread,
Expand Down Expand Up @@ -102,17 +105,28 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
const { canInterrupt, awaitingUserQuestion } = useWorkspaceSidebarState(workspaceId);
const isWorking = canInterrupt && !awaitingUserQuestion;

// Sidebar indentation constants
const BASE_PADDING_PX = 9;
const NESTING_INDENT_PX = 16;

// Calculate left padding based on nesting depth
const leftPadding = BASE_PADDING_PX + nestingDepth * NESTING_INDENT_PX;
const isAgentTask = Boolean(metadata.parentWorkspaceId);

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"
isDeleting && "pointer-events-none",
// Nested agent tasks get a subtle visual indicator
isAgentTask && "border-l-purple-400/30"
)}
style={{ paddingLeft: `${leftPadding}px` }}
onClick={() => {
if (isDisabled) return;
onSelectWorkspace({
Expand Down
21 changes: 13 additions & 8 deletions src/browser/hooks/useSortedWorkspacesByProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import { useWorkspaceRecency } from "@/browser/stores/WorkspaceStore";
import { useStableReference, compareMaps } from "@/browser/hooks/useStableReference";
import { sortWithNesting, type WorkspaceWithNesting } from "@/browser/utils/ui/workspaceFiltering";

// Re-export for backward compatibility
export type { WorkspaceWithNesting };

export function useSortedWorkspacesByProject() {
const { projects } = useProjectContext();
Expand All @@ -11,19 +15,16 @@ export function useSortedWorkspacesByProject() {

return useStableReference(
() => {
const result = new Map<string, FrontendWorkspaceMetadata[]>();
const result = new Map<string, WorkspaceWithNesting[]>();
for (const [projectPath, config] of projects) {
const metadataList = config.workspaces
.map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined))
.filter((meta): meta is FrontendWorkspaceMetadata => Boolean(meta));

metadataList.sort((a, b) => {
const aTimestamp = workspaceRecency[a.id] ?? 0;
const bTimestamp = workspaceRecency[b.id] ?? 0;
return bTimestamp - aTimestamp;
});
// Sort with nesting: parents first, children indented below
const sorted = sortWithNesting(metadataList, workspaceRecency);

result.set(projectPath, metadataList);
result.set(projectPath, sorted);
}
return result;
},
Expand All @@ -37,7 +38,11 @@ export function useSortedWorkspacesByProject() {
if (!other) {
return false;
}
return metadata.id === other.id && metadata.name === other.name;
return (
metadata.id === other.id &&
metadata.name === other.name &&
metadata.nestingDepth === other.nestingDepth
);
});
}),
[projects, workspaceMetadata, workspaceRecency]
Expand Down
8 changes: 8 additions & 0 deletions src/browser/stories/App.settings.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,11 @@ export const ExperimentsToggleOff: AppStory = {
// Default state is OFF - no clicks needed
},
};

/** Tasks section - shows agent task limits configuration */
export const Tasks: AppStory = {
render: () => <AppWithMocks setup={() => setupSettingsStory({})} />,
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
await openSettingsToSection(canvasElement, "tasks");
},
};
Loading