diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index ff6034b3..434f761b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index cb6e82ff..292ce178 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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): 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 | 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. diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 25d2a975..4894939a 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -82,13 +82,6 @@ export const ToolApprovalSheet: React.FC = () => { const containerRef = useRef(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; diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index cce545a8..b8093241 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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'; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 0b8993c5..94b5bc88 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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" /> + void; + id?: string; +} + +export const EffortLevelSelector: React.FC = ({ + value, + onValueChange, + id, +}) => ( +
+ +
+ {EFFORT_OPTIONS.map((opt) => ( + + ))} +
+

+ Controls how much reasoning Claude invests before responding. Default uses Claude's + standard behavior. +

+
+); diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 693ca2e2..37160b57 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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" /> + ; + toggle: (messageKey: string) => void; +} { + const [version, setVersion] = useState(0); + const expandedSet = useMemo(() => { + if (version < 0) return new Set(); + return teamName ? getExpandedOverrides(teamName) : new Set(); + }, [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 }; +} diff --git a/src/renderer/utils/teamMessageExpandStorage.ts b/src/renderer/utils/teamMessageExpandStorage.ts new file mode 100644 index 00000000..d97626e1 --- /dev/null +++ b/src/renderer/utils/teamMessageExpandStorage.ts @@ -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 { + 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 + } +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index d87cc48f..5ce1986e 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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; }