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

View file

@ -1804,6 +1804,7 @@ export class TeamProvisioningService {
'TeamDelete,TodoWrite', 'TeamDelete,TodoWrite',
...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []), ...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []),
...(request.model ? ['--model', request.model] : []), ...(request.model ? ['--model', request.model] : []),
...(request.effort ? ['--effort', request.effort] : []),
]; ];
try { try {
child = spawnCli(claudePath, spawnArgs, { child = spawnCli(claudePath, spawnArgs, {
@ -2137,6 +2138,9 @@ export class TeamProvisioningService {
if (request.model) { if (request.model) {
launchArgs.push('--model', 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 // 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. // --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. * 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 { private handleControlRequest(run: ProvisioningRun, msg: Record<string, unknown>): void {
const requestId = typeof msg.request_id === 'string' ? msg.request_id : null; 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 request = msg.request as Record<string, unknown> | undefined;
const subtype = request?.subtype; 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') { if (subtype !== 'can_use_tool') {
logger.debug( 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; return;
} }
@ -3257,6 +3266,34 @@ export class TeamProvisioningService {
this.emitToolApprovalEvent(approval); 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. * Respond to a pending tool approval sends control_response to CLI stdin.
* Validates runId match and requestId existence before writing. * 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 containerRef = useRef<HTMLDivElement>(null);
const [disabled, setDisabled] = useState(false); 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( const handleRespond = useCallback(
(allow: boolean) => { (allow: boolean) => {
if (!current || disabled) return; if (!current || disabled) return;

View file

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

View file

@ -30,6 +30,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColor } from '@shared/constants/memberColors';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { EffortLevelSelector } from './EffortLevelSelector';
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox'; import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox'; import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
@ -50,6 +51,7 @@ const TEAM_COLOR_NAMES = [
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { import type {
EffortLevel,
Project, Project,
TeamCreateRequest, TeamCreateRequest,
TeamProvisioningMemberInput, TeamProvisioningMemberInput,
@ -237,6 +239,9 @@ export const CreateTeamDialog = ({
const [skipPermissions, setSkipPermissionsRaw] = useState( const [skipPermissions, setSkipPermissionsRaw] = useState(
() => localStorage.getItem('team:lastSkipPermissions') !== 'false' () => localStorage.getItem('team:lastSkipPermissions') !== 'false'
); );
const [selectedEffort, setSelectedEffortRaw] = useState(
() => localStorage.getItem('team:lastSelectedEffort') ?? ''
);
const setSelectedModel = (value: string): void => { const setSelectedModel = (value: string): void => {
setSelectedModelRaw(value); setSelectedModelRaw(value);
@ -253,6 +258,11 @@ export const CreateTeamDialog = ({
localStorage.setItem('team:lastSkipPermissions', String(value)); localStorage.setItem('team:lastSkipPermissions', String(value));
}; };
const setSelectedEffort = (value: string): void => {
setSelectedEffortRaw(value);
localStorage.setItem('team:lastSelectedEffort', value);
};
const resetUIState = (): void => { const resetUIState = (): void => {
setLocalError(null); setLocalError(null);
setFieldErrors({}); setFieldErrors({});
@ -482,6 +492,7 @@ export const CreateTeamDialog = ({
cwd: effectiveCwd, cwd: effectiveCwd,
prompt: prompt.trim() || undefined, prompt: prompt.trim() || undefined,
model: effectiveModel, model: effectiveModel,
effort: (selectedEffort as EffortLevel) || undefined,
skipPermissions, skipPermissions,
}), }),
[ [
@ -493,6 +504,7 @@ export const CreateTeamDialog = ({
effectiveCwd, effectiveCwd,
prompt, prompt,
effectiveModel, effectiveModel,
selectedEffort,
skipPermissions, skipPermissions,
] ]
); );
@ -806,6 +818,11 @@ export const CreateTeamDialog = ({
onValueChange={setSelectedModel} onValueChange={setSelectedModel}
id="create-model" id="create-model"
/> />
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="create-effort"
/>
<ExtendedContextCheckbox <ExtendedContextCheckbox
id="create-extended-context" id="create-extended-context"
checked={extendedContext} 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 { normalizePath } from '@renderer/utils/pathNormalize';
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, X } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, X } from 'lucide-react';
import { EffortLevelSelector } from './EffortLevelSelector';
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
import type { ActiveTeamRef } from './CreateTeamDialog'; import type { ActiveTeamRef } from './CreateTeamDialog';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { import type {
EffortLevel,
Project, Project,
ResolvedTeamMember, ResolvedTeamMember,
TeamLaunchRequest, TeamLaunchRequest,
@ -82,6 +84,9 @@ export const LaunchTeamDialog = ({
const [skipPermissions, setSkipPermissionsRaw] = useState( const [skipPermissions, setSkipPermissionsRaw] = useState(
() => localStorage.getItem('team:lastSkipPermissions') !== 'false' () => localStorage.getItem('team:lastSkipPermissions') !== 'false'
); );
const [selectedEffort, setSelectedEffortRaw] = useState(
() => localStorage.getItem('team:lastSelectedEffort') ?? ''
);
const [clearContext, setClearContext] = useState(false); const [clearContext, setClearContext] = useState(false);
const [conflictDismissed, setConflictDismissed] = useState(false); const [conflictDismissed, setConflictDismissed] = useState(false);
@ -100,6 +105,11 @@ export const LaunchTeamDialog = ({
localStorage.setItem('team:lastSkipPermissions', String(value)); localStorage.setItem('team:lastSkipPermissions', String(value));
}; };
const setSelectedEffort = (value: string): void => {
setSelectedEffortRaw(value);
localStorage.setItem('team:lastSelectedEffort', value);
};
const resetFormState = (): void => { const resetFormState = (): void => {
setLocalError(null); setLocalError(null);
setIsSubmitting(false); setIsSubmitting(false);
@ -291,6 +301,7 @@ export const LaunchTeamDialog = ({
cwd: effectiveCwd, cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined, prompt: promptDraft.value.trim() || undefined,
model: computeEffectiveTeamModel(selectedModel, extendedContext), model: computeEffectiveTeamModel(selectedModel, extendedContext),
effort: (selectedEffort as EffortLevel) || undefined,
clearContext: clearContext || undefined, clearContext: clearContext || undefined,
skipPermissions, skipPermissions,
}); });
@ -435,6 +446,11 @@ export const LaunchTeamDialog = ({
onValueChange={setSelectedModel} onValueChange={setSelectedModel}
id="launch-model" id="launch-model"
/> />
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="launch-effort"
/>
<ExtendedContextCheckbox <ExtendedContextCheckbox
id="launch-extended-context" id="launch-extended-context"
checked={extendedContext} 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; isAlive?: boolean;
} }
export type EffortLevel = 'low' | 'medium' | 'high';
export interface TeamLaunchRequest { export interface TeamLaunchRequest {
teamName: string; teamName: string;
cwd: string; cwd: string;
prompt?: string; prompt?: string;
model?: string; model?: string;
effort?: EffortLevel;
/** When true, skip --resume and start a fresh session (clears context memory). */ /** When true, skip --resume and start a fresh session (clears context memory). */
clearContext?: boolean; clearContext?: boolean;
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
@ -385,6 +388,7 @@ export interface TeamCreateRequest {
cwd: string; cwd: string;
prompt?: string; prompt?: string;
model?: string; model?: string;
effort?: EffortLevel;
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */ /** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
skipPermissions?: boolean; skipPermissions?: boolean;
} }