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:
parent
3e605622f7
commit
964a8772ea
10 changed files with 215 additions and 11 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
51
src/renderer/components/team/dialogs/EffortLevelSelector.tsx
Normal file
51
src/renderer/components/team/dialogs/EffortLevelSelector.tsx
Normal 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's
|
||||
standard behavior.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
34
src/renderer/hooks/useTeamMessagesExpanded.ts
Normal file
34
src/renderer/hooks/useTeamMessagesExpanded.ts
Normal 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 };
|
||||
}
|
||||
39
src/renderer/utils/teamMessageExpandStorage.ts
Normal file
39
src/renderer/utils/teamMessageExpandStorage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue