feat: add effort level management to team provisioning and dialogs

- Introduced EffortLevel type to define valid effort levels ('low', 'medium', 'high').
- Updated TeamCreateRequest and TeamLaunchRequest interfaces to include effort parameter.
- Enhanced CreateTeamDialog and LaunchTeamDialog to allow users to select effort levels.
- Integrated effort level handling in provisioning requests and responses within TeamProvisioningService.
- Removed auto-focus effect in ToolApprovalSheet for improved user experience.
This commit is contained in:
iliya 2026-03-06 21:28:12 +02:00
parent 3e605622f7
commit 964a8772ea
10 changed files with 215 additions and 11 deletions

View file

@ -96,6 +96,7 @@ import type {
AttachmentMeta,
AttachmentPayload,
CreateTaskRequest,
EffortLevel,
GlobalTask,
IpcResult,
KanbanColumnId,
@ -548,6 +549,12 @@ function isProvisioningTeamName(teamName: string): boolean {
return parts.every((p) => /^[a-z0-9]+$/.test(p));
}
const VALID_EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high'];
function isValidEffort(value: unknown): value is EffortLevel {
return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value);
}
async function validateProvisioningRequest(
request: unknown
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
@ -645,6 +652,7 @@ async function validateProvisioningRequest(
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
},
@ -751,6 +759,7 @@ async function handleLaunchTeam(
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
clearContext: payload.clearContext === true ? true : undefined,
skipPermissions:
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,

View file

@ -1804,6 +1804,7 @@ export class TeamProvisioningService {
'TeamDelete,TodoWrite',
...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []),
...(request.model ? ['--model', request.model] : []),
...(request.effort ? ['--effort', request.effort] : []),
];
try {
child = spawnCli(claudePath, spawnArgs, {
@ -2137,6 +2138,9 @@ export class TeamProvisioningService {
if (request.model) {
launchArgs.push('--model', request.model);
}
if (request.effort) {
launchArgs.push('--effort', request.effort);
}
// New sessions: CLI creates its own ID. No --resume with synthetic name — docs say
// --resume is for existing sessions and may show an interactive picker if not found.
@ -3222,7 +3226,8 @@ export class TeamProvisioningService {
/**
* Handles a control_request message from CLI stream-json output.
* Only `can_use_tool` subtype is processed others are logged and ignored.
* `can_use_tool` emits to renderer for manual approval.
* All other subtypes (hook_callback, etc.) auto-allowed to prevent deadlock.
*/
private handleControlRequest(run: ProvisioningRun, msg: Record<string, unknown>): void {
const requestId = typeof msg.request_id === 'string' ? msg.request_id : null;
@ -3233,10 +3238,14 @@ export class TeamProvisioningService {
const request = msg.request as Record<string, unknown> | undefined;
const subtype = request?.subtype;
// Non-`can_use_tool` subtypes (hook_callback, etc.) are auto-allowed to prevent
// CLI deadlock — hooks are user-configured and should not block on manual approval.
if (subtype !== 'can_use_tool') {
logger.debug(
`[${run.teamName}] control_request subtype=${String(subtype)}, ignoring (only can_use_tool supported)`
`[${run.teamName}] control_request subtype=${String(subtype)}, auto-allowing to prevent deadlock`
);
this.autoAllowControlRequest(run, requestId);
return;
}
@ -3257,6 +3266,34 @@ export class TeamProvisioningService {
this.emitToolApprovalEvent(approval);
}
/**
* Immediately sends an "allow" control_response for a non-tool control_request.
* Prevents CLI deadlock for hook_callback and other non-`can_use_tool` subtypes.
*/
private autoAllowControlRequest(run: ProvisioningRun, requestId: string): void {
if (!run.child?.stdin?.writable) {
logger.warn(`[${run.teamName}] Cannot auto-allow control_request: stdin not writable`);
return;
}
const response = {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: { behavior: 'allow' },
},
};
run.child.stdin.write(JSON.stringify(response) + '\n', (err) => {
if (err) {
logger.error(
`[${run.teamName}] Failed to auto-allow control_request ${requestId}: ${err.message}`
);
}
});
}
/**
* Respond to a pending tool approval sends control_response to CLI stdin.
* Validates runId match and requestId existence before writing.

View file

@ -82,13 +82,6 @@ export const ToolApprovalSheet: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null);
const [disabled, setDisabled] = useState(false);
// Auto-focus when new approval arrives
useEffect(() => {
if (current && containerRef.current) {
containerRef.current.focus();
}
}, [current?.requestId]); // eslint-disable-line react-hooks/exhaustive-deps
const handleRespond = useCallback(
(allow: boolean) => {
if (!current || disabled) return;

View file

@ -54,6 +54,8 @@ interface ActivityItemProps {
zebraShade?: boolean;
/** When true, collapse message body — show only header with expand chevron. */
forceCollapsed?: boolean;
/** Called when user toggles expand/collapse in collapsed mode. Presence enables chevron. */
onCollapseToggle?: () => void;
}
function getStringField(obj: StructuredMessage, key: string): string | null {
@ -216,6 +218,7 @@ export const ActivityItem = ({
onRestartTeam,
zebraShade,
forceCollapsed,
onCollapseToggle,
}: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const formattedRole = formatAgentRole(memberRole);
@ -295,8 +298,9 @@ export const ActivityItem = ({
onCreateTask?.(subject, description);
};
const isHeaderClickable = Boolean(systemLabel) || forceCollapsed === true;
const showChevron = Boolean(systemLabel) || forceCollapsed === true;
const isHeaderClickable =
Boolean(systemLabel) || forceCollapsed === true || onCollapseToggle != null;
const showChevron = isHeaderClickable;
const isUserSent = message.source === 'user_sent';
const isSystemMessage = message.from === 'system';

View file

@ -30,6 +30,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { EffortLevelSelector } from './EffortLevelSelector';
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
import { ProjectPathSelector } from './ProjectPathSelector';
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
@ -50,6 +51,7 @@ const TEAM_COLOR_NAMES = [
import type { MentionSuggestion } from '@renderer/types/mention';
import type {
EffortLevel,
Project,
TeamCreateRequest,
TeamProvisioningMemberInput,
@ -237,6 +239,9 @@ export const CreateTeamDialog = ({
const [skipPermissions, setSkipPermissionsRaw] = useState(
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
);
const [selectedEffort, setSelectedEffortRaw] = useState(
() => localStorage.getItem('team:lastSelectedEffort') ?? ''
);
const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value);
@ -253,6 +258,11 @@ export const CreateTeamDialog = ({
localStorage.setItem('team:lastSkipPermissions', String(value));
};
const setSelectedEffort = (value: string): void => {
setSelectedEffortRaw(value);
localStorage.setItem('team:lastSelectedEffort', value);
};
const resetUIState = (): void => {
setLocalError(null);
setFieldErrors({});
@ -482,6 +492,7 @@ export const CreateTeamDialog = ({
cwd: effectiveCwd,
prompt: prompt.trim() || undefined,
model: effectiveModel,
effort: (selectedEffort as EffortLevel) || undefined,
skipPermissions,
}),
[
@ -493,6 +504,7 @@ export const CreateTeamDialog = ({
effectiveCwd,
prompt,
effectiveModel,
selectedEffort,
skipPermissions,
]
);
@ -806,6 +818,11 @@ export const CreateTeamDialog = ({
onValueChange={setSelectedModel}
id="create-model"
/>
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="create-effort"
/>
<ExtendedContextCheckbox
id="create-extended-context"
checked={extendedContext}

View file

@ -0,0 +1,51 @@
import React from 'react';
import { Label } from '@renderer/components/ui/label';
import { cn } from '@renderer/lib/utils';
const EFFORT_OPTIONS = [
{ value: '', label: 'Default' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
] as const;
export interface EffortLevelSelectorProps {
value: string;
onValueChange: (value: string) => void;
id?: string;
}
export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
value,
onValueChange,
id,
}) => (
<div className="mb-3">
<Label htmlFor={id} className="label-optional mb-1.5 block">
Effort level (optional)
</Label>
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{EFFORT_OPTIONS.map((opt) => (
<button
key={opt.value || '__default__'}
type="button"
id={opt.value === value ? id : undefined}
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
value === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => onValueChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
Controls how much reasoning Claude invests before responding. Default uses Claude&apos;s
standard behavior.
</p>
</div>
);

View file

@ -24,12 +24,14 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, X } from 'lucide-react';
import { EffortLevelSelector } from './EffortLevelSelector';
import { ProjectPathSelector } from './ProjectPathSelector';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
import type { ActiveTeamRef } from './CreateTeamDialog';
import type { MentionSuggestion } from '@renderer/types/mention';
import type {
EffortLevel,
Project,
ResolvedTeamMember,
TeamLaunchRequest,
@ -82,6 +84,9 @@ export const LaunchTeamDialog = ({
const [skipPermissions, setSkipPermissionsRaw] = useState(
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
);
const [selectedEffort, setSelectedEffortRaw] = useState(
() => localStorage.getItem('team:lastSelectedEffort') ?? ''
);
const [clearContext, setClearContext] = useState(false);
const [conflictDismissed, setConflictDismissed] = useState(false);
@ -100,6 +105,11 @@ export const LaunchTeamDialog = ({
localStorage.setItem('team:lastSkipPermissions', String(value));
};
const setSelectedEffort = (value: string): void => {
setSelectedEffortRaw(value);
localStorage.setItem('team:lastSelectedEffort', value);
};
const resetFormState = (): void => {
setLocalError(null);
setIsSubmitting(false);
@ -291,6 +301,7 @@ export const LaunchTeamDialog = ({
cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined,
model: computeEffectiveTeamModel(selectedModel, extendedContext),
effort: (selectedEffort as EffortLevel) || undefined,
clearContext: clearContext || undefined,
skipPermissions,
});
@ -435,6 +446,11 @@ export const LaunchTeamDialog = ({
onValueChange={setSelectedModel}
id="launch-model"
/>
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="launch-effort"
/>
<ExtendedContextCheckbox
id="launch-extended-context"
checked={extendedContext}

View file

@ -0,0 +1,34 @@
import { useCallback, useMemo, useState } from 'react';
import {
addExpanded,
getExpandedOverrides,
removeExpanded,
} from '@renderer/utils/teamMessageExpandStorage';
export function useTeamMessagesExpanded(teamName: string): {
expandedSet: Set<string>;
toggle: (messageKey: string) => void;
} {
const [version, setVersion] = useState(0);
const expandedSet = useMemo(() => {
if (version < 0) return new Set<string>();
return teamName ? getExpandedOverrides(teamName) : new Set<string>();
}, [teamName, version]);
const toggle = useCallback(
(messageKey: string) => {
if (!teamName) return;
const existing = getExpandedOverrides(teamName);
if (existing.has(messageKey)) {
removeExpanded(teamName, messageKey);
} else {
addExpanded(teamName, messageKey);
}
setVersion((v) => v + 1);
},
[teamName]
);
return { expandedSet, toggle };
}

View file

@ -0,0 +1,39 @@
const STORAGE_PREFIX = 'team-msg-expanded:';
function storageKey(teamName: string): string {
return `${STORAGE_PREFIX}${teamName}`;
}
export function getExpandedOverrides(teamName: string): Set<string> {
try {
const raw = localStorage.getItem(storageKey(teamName));
if (!raw) return new Set();
const arr = JSON.parse(raw) as unknown;
if (!Array.isArray(arr)) return new Set();
return new Set(arr.filter((x): x is string => typeof x === 'string'));
} catch {
return new Set();
}
}
export function addExpanded(teamName: string, messageKey: string): void {
try {
const set = getExpandedOverrides(teamName);
if (set.has(messageKey)) return;
set.add(messageKey);
localStorage.setItem(storageKey(teamName), JSON.stringify([...set]));
} catch {
// quota or disabled
}
}
export function removeExpanded(teamName: string, messageKey: string): void {
try {
const set = getExpandedOverrides(teamName);
if (!set.has(messageKey)) return;
set.delete(messageKey);
localStorage.setItem(storageKey(teamName), JSON.stringify([...set]));
} catch {
// quota or disabled
}
}

View file

@ -296,11 +296,14 @@ export interface TeamData {
isAlive?: boolean;
}
export type EffortLevel = 'low' | 'medium' | 'high';
export interface TeamLaunchRequest {
teamName: string;
cwd: string;
prompt?: string;
model?: string;
effort?: EffortLevel;
/** When true, skip --resume and start a fresh session (clears context memory). */
clearContext?: boolean;
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
@ -385,6 +388,7 @@ export interface TeamCreateRequest {
cwd: string;
prompt?: string;
model?: string;
effort?: EffortLevel;
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
skipPermissions?: boolean;
}