feat: session goals - server-driven goal loop with independent small-model audit (#2148)
Arm the target button in the composer and the next prompt becomes a goal: the server keeps the session working toward it (idle tick -> small-model audit -> continuation) until the objective is verifiably complete, blocked, or out of budget — even with the UI closed. Server (packages/web/server/lib/session-goal): - event-driven loop on the global SSE hub; goal state lives in session.metadata.openchamber.goal (merge-safe patches, stale-write guard by goal id), so it survives restarts and syncs to every client for free - the small-model audit (objective + last assistant turn only, language pinned to the objective) is the sole termination authority; blocked needs 3 consecutive verdicts, audit outages tolerate one unaudited continuation then stop the goal as resumable-blocked - hard stops: optional token budget, auto-continuation cap (Resume grants a fresh allowance), turn errors; user abort pauses the goal instead of blocking it, and resuming over an aborted tail nudges immediately - token accounting as a snapshot of the latest turn (input + cache.read + output), goal-relative via a creation baseline and segmented across compactions; a compaction summary skips the audit and continues - continuations reuse the session's own provider/model/agent/variant UI: - three-mode target button (arm / disarm / manage dialog), informational goal strip with inline pause/resume and an Evaluating indicator, sidebar state glyph, objective length counter (2000-char server clamp), read-only completed goals - goal entry points: composer (sessions and drafts), start-new-session- from-answer dialog, plan implement dialog (plan content becomes the objective), scheduled tasks (Run as goal + budget) - Settings -> Chat -> Goal: feature toggle + default token budget with three-layer parity (web server, client persistence, VS Code bridge); VS Code renders goal state but hides the entry points (the loop runs in the web server only) Notifications: per-turn "ready" notifications are suppressed while a goal is active; settling sends one final notification (desktop, web-push, APNs generic titles with the session name as body) honoring the completion toggle. Error/question/permission notifications are untouched. Docs: user guide (session-goals) in all 9 locales + sidebar entry, scheduled-tasks cross-reference, server module DOCUMENTATION.md.
This commit is contained in:
committed by
GitHub
parent
82c039117a
commit
bb45164ae8
@@ -100,6 +100,8 @@ import {
|
||||
} from './attachmentCitations';
|
||||
import { getFileMentionAutocompleteQuery, type FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState';
|
||||
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
|
||||
import { SessionGoalRow } from '@/components/chat/SessionGoalRow';
|
||||
import { SessionGoalButton, SessionGoalObjectiveCounter } from '@/components/chat/SessionGoalButton';
|
||||
import type { Part } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
@@ -4904,6 +4906,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
>
|
||||
{isMobile && !mobileComposerExpanded ? (
|
||||
<div className="flex flex-col">
|
||||
<SessionGoalRow
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
className="mb-1.5"
|
||||
/>
|
||||
<SessionSuggestionChip
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
@@ -4986,6 +4993,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SessionGoalRow
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
className="mb-1.5"
|
||||
/>
|
||||
<SessionSuggestionChip
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
@@ -5313,6 +5325,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
|
||||
handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle}
|
||||
/>
|
||||
<SessionGoalButton
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
draftOpen={newSessionDraftOpen}
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
iconSizeClass={iconSizeClass}
|
||||
/>
|
||||
<SessionGoalObjectiveCounter length={message.length} />
|
||||
</div>
|
||||
<div className="flex items-center min-w-0 gap-x-1 justify-end">
|
||||
<div className="flex items-center gap-x-1 flex-shrink-0">
|
||||
@@ -5382,6 +5402,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle}
|
||||
withTooltip
|
||||
/>
|
||||
<SessionGoalButton
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
draftOpen={newSessionDraftOpen}
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
iconSizeClass={iconSizeClass}
|
||||
withTooltip
|
||||
/>
|
||||
<SessionGoalObjectiveCounter length={message.length} />
|
||||
</div>
|
||||
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
||||
<MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore';
|
||||
import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata';
|
||||
import { SessionGoalDialog } from '@/components/chat/SessionGoalDialog';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SessionGoalButtonProps {
|
||||
sessionId: string | null;
|
||||
directory?: string;
|
||||
/** Session draft is open — the goal arms for the session the draft creates. */
|
||||
draftOpen?: boolean;
|
||||
footerIconButtonClass: string;
|
||||
iconSizeClass: string;
|
||||
withTooltip?: boolean;
|
||||
}
|
||||
|
||||
// Composer target button — the goal switch. With no live goal one tap arms
|
||||
// goal mode (the next sent prompt becomes the objective; works on drafts
|
||||
// too) and a second tap disarms. While a goal is live the target stays lit
|
||||
// (info while running, success when complete, error when blocked / out of
|
||||
// budget) and tapping opens the manage dialog.
|
||||
export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
|
||||
sessionId,
|
||||
directory,
|
||||
draftOpen = false,
|
||||
footerIconButtonClass,
|
||||
iconSizeClass,
|
||||
withTooltip = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { goal, enabled } = useSessionGoal(sessionId ?? '', directory);
|
||||
const armed = useSessionGoalArmStore((state) => state.armed);
|
||||
const setArmed = useSessionGoalArmStore((state) => state.setArmed);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
|
||||
// The goal loop runs in the web server; the VS Code extension only renders
|
||||
// goal state. Arming a goal there would create one nothing drives, so the
|
||||
// entry point is hidden entirely.
|
||||
if (isVSCodeRuntime() || !enabled || (!sessionId && !draftOpen)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A settled goal no longer drives the loop — the button goes back to being
|
||||
// an arm switch, while still tinting with the outcome color.
|
||||
const liveGoal = goal && goal.status !== 'complete' ? goal : null;
|
||||
const isEngaged = armed || Boolean(liveGoal);
|
||||
|
||||
const colorClass = (() => {
|
||||
if (goal?.status === 'complete') return 'text-[var(--status-success)]';
|
||||
if (goal?.status === 'blocked' || goal?.status === 'budgetLimited') return 'text-[var(--status-error)]';
|
||||
if (armed || goal?.status === 'active' || goal?.status === 'paused') return 'text-[var(--status-info)]';
|
||||
return '';
|
||||
})();
|
||||
|
||||
const label = goal
|
||||
? t('chat.goal.button.manageAria')
|
||||
: (armed ? t('chat.goal.button.disarmAria') : t('chat.goal.button.armAria'));
|
||||
|
||||
// Any existing goal (live or completed) opens the manage dialog — a
|
||||
// completed goal must be removed there before a new one can be armed.
|
||||
const handleClick = () => {
|
||||
if (goal) {
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
setArmed(!armed);
|
||||
};
|
||||
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(footerIconButtonClass, colorClass)}
|
||||
onClick={handleClick}
|
||||
aria-label={label}
|
||||
aria-pressed={isEngaged}
|
||||
{...(withTooltip ? {} : { title: label })}
|
||||
>
|
||||
{isEngaged || goal ? (
|
||||
<Icon name="target-fill" className={cn(iconSizeClass, 'text-current')} aria-hidden="true" />
|
||||
) : (
|
||||
<Icon name="target" className={cn(iconSizeClass, 'text-current')} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{withTooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : button}
|
||||
{sessionId ? (
|
||||
<SessionGoalDialog open={dialogOpen} onOpenChange={setDialogOpen} sessionId={sessionId} directory={directory} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
SessionGoalButton.displayName = 'SessionGoalButton';
|
||||
|
||||
interface SessionGoalObjectiveCounterProps {
|
||||
/** Current composer text length — the armed message becomes the objective. */
|
||||
length: number;
|
||||
}
|
||||
|
||||
// Tiny hot-path leaf next to the target button: while goal mode is armed the
|
||||
// typed message becomes the objective, which the server clamps to 2000
|
||||
// chars — surface that limit during typing instead of truncating silently.
|
||||
// Renders null when not armed, so normal typing shows nothing.
|
||||
export const SessionGoalObjectiveCounter: React.FC<SessionGoalObjectiveCounterProps> = React.memo(({ length }) => {
|
||||
const { t } = useI18n();
|
||||
const armed = useSessionGoalArmStore((state) => state.armed);
|
||||
|
||||
if (!armed || length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const over = length > SESSION_GOAL_OBJECTIVE_CHAR_LIMIT;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex-shrink-0 self-center typography-micro tabular-nums',
|
||||
over ? 'text-[var(--status-error)]' : 'text-muted-foreground/70',
|
||||
)}
|
||||
aria-label={t('chat.goal.counter.aria')}
|
||||
title={t('chat.goal.counter.aria')}
|
||||
>
|
||||
{length}/{SESSION_GOAL_OBJECTIVE_CHAR_LIMIT}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
SessionGoalObjectiveCounter.displayName = 'SessionGoalObjectiveCounter';
|
||||
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import {
|
||||
formatGoalTokens,
|
||||
SESSION_GOAL_OBJECTIVE_CHAR_LIMIT,
|
||||
} from '@/lib/sessionGoalMetadata';
|
||||
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
|
||||
import { clearSessionGoal, setSessionGoal } from '@/lib/sessionGoalActions';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface SessionGoalDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: string;
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
// Create/manage dialog for the session goal: objective + optional token
|
||||
// budget on creation; status, usage, latest audit note and lifecycle actions
|
||||
// (pause/resume/complete/clear) once a goal exists.
|
||||
export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }: SessionGoalDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { goal } = useSessionGoal(sessionId, directory);
|
||||
|
||||
const [objective, setObjective] = React.useState('');
|
||||
const [budgetEnabled, setBudgetEnabled] = React.useState(false);
|
||||
const [tokenBudget, setTokenBudget] = React.useState<number>(200_000);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setObjective(goal?.objective ?? '');
|
||||
setBudgetEnabled(Boolean(goal?.tokenBudget));
|
||||
setTokenBudget(goal?.tokenBudget ?? 200_000);
|
||||
// Seed the form only when the dialog opens; live goal updates while it is
|
||||
// open must not clobber the user's edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const run = React.useCallback(async (action: () => Promise<void>, closeAfter: boolean) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await action();
|
||||
if (closeAfter) onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.warn('[session-goal] action failed:', error);
|
||||
toast.error(t('chat.goal.toast.actionFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [onOpenChange, t]);
|
||||
|
||||
const trimmedObjective = objective.trim();
|
||||
const objectiveChanged = trimmedObjective !== (goal?.objective ?? '');
|
||||
const budgetValue = budgetEnabled ? tokenBudget : null;
|
||||
const budgetChanged = budgetValue !== (goal?.tokenBudget ?? null);
|
||||
// A completed goal is read-only: remove it and arm a new one instead of
|
||||
// "saving" over the outcome (re-saving used to spawn a fresh active goal
|
||||
// that the auditor instantly re-completed — a confusing status flash).
|
||||
const isCompleted = goal?.status === 'complete';
|
||||
const canSave = !isCompleted && trimmedObjective.length > 0 && (!goal || objectiveChanged || budgetChanged);
|
||||
|
||||
const handleSave = () => run(
|
||||
() => setSessionGoal(sessionId, directory, { objective: trimmedObjective, tokenBudget: budgetValue }, goal),
|
||||
true,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{goal ? t('chat.goal.dialog.titleManage') : t('chat.goal.dialog.titleCreate')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{goal && (
|
||||
<div className="space-y-1 p-2 rounded-lg" style={{ backgroundColor: 'var(--surface-elevated)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full flex-shrink-0" style={{ backgroundColor: sessionGoalStatusColor[goal.status] }} aria-hidden="true" />
|
||||
<span className="typography-ui-label text-foreground">{t(sessionGoalStatusLabelKey[goal.status] as never)}</span>
|
||||
<span className="typography-meta text-muted-foreground tabular-nums">
|
||||
{goal.tokenBudget
|
||||
? t('chat.goal.usage.tokensWithBudget', {
|
||||
used: formatGoalTokens(goal.tokensUsed),
|
||||
budget: formatGoalTokens(goal.tokenBudget),
|
||||
})
|
||||
: t('chat.goal.usage.tokens', { used: formatGoalTokens(goal.tokensUsed) })}
|
||||
{' · '}
|
||||
{t('chat.goal.usage.turns', { turns: goal.turnsUsed })}
|
||||
</span>
|
||||
</div>
|
||||
{goal.note ? (
|
||||
<p className="typography-meta text-muted-foreground">{goal.note}</p>
|
||||
) : null}
|
||||
{/* Only failure states carry a reason worth reading; outcomes
|
||||
like "verified by audit" are noise next to the status dot. */}
|
||||
{goal.statusReason && (goal.status === 'blocked' || goal.status === 'budgetLimited') ? (
|
||||
<p className="typography-meta text-muted-foreground/70">{goal.statusReason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCompleted ? (
|
||||
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words typography-meta text-muted-foreground">{goal.objective}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="typography-ui-label text-foreground">{t('chat.goal.dialog.objectiveLabel')}</span>
|
||||
<span className="typography-micro tabular-nums text-muted-foreground/70" aria-label={t('chat.goal.counter.aria')}>
|
||||
{objective.length}/{SESSION_GOAL_OBJECTIVE_CHAR_LIMIT}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={objective}
|
||||
onChange={(event) => setObjective(event.target.value)}
|
||||
placeholder={t('chat.goal.dialog.objectivePlaceholder')}
|
||||
maxLength={SESSION_GOAL_OBJECTIVE_CHAR_LIMIT}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={budgetEnabled}
|
||||
onClick={() => setBudgetEnabled((value) => !value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setBudgetEnabled((value) => !value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={budgetEnabled}
|
||||
onChange={setBudgetEnabled}
|
||||
ariaLabel={t('chat.goal.dialog.budgetLabel')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('chat.goal.dialog.budgetLabel')}</span>
|
||||
</div>
|
||||
{budgetEnabled && (
|
||||
<NumberInput
|
||||
value={tokenBudget}
|
||||
onValueChange={(value) => setTokenBudget(typeof value === 'number' && value > 0 ? Math.floor(value) : 1000)}
|
||||
min={1000}
|
||||
max={100_000_000}
|
||||
step={50_000}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{goal && (
|
||||
<Button variant="destructive" size="sm" disabled={busy} onClick={() => run(() => clearSessionGoal(sessionId, directory), true)}>
|
||||
{t('chat.goal.action.clear')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-1 items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => onOpenChange(false)}>
|
||||
{t('chat.goal.action.cancel')}
|
||||
</Button>
|
||||
{!isCompleted && (
|
||||
<Button size="sm" disabled={busy || !canSave} onClick={handleSave}>
|
||||
{goal ? t('chat.goal.action.save') : t('chat.goal.action.start')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useSessionStatus } from '@/sync/sync-context';
|
||||
import { useSessionGoal } from '@/hooks/useSessionGoal';
|
||||
import { formatGoalTokens } from '@/lib/sessionGoalMetadata';
|
||||
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
|
||||
import { setSessionGoalStatus } from '@/lib/sessionGoalActions';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SessionGoalRowProps {
|
||||
sessionId: string | null;
|
||||
directory?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Compact goal strip near the composer: informational only — status dot,
|
||||
// objective (or the latest audit note), token usage — plus an inline
|
||||
// pause/resume action. The manage dialog opens from the composer target
|
||||
// button, not from here.
|
||||
export const SessionGoalRow: React.FC<SessionGoalRowProps> = React.memo(({ sessionId, directory, className }) => {
|
||||
const { t } = useI18n();
|
||||
const { goal, enabled } = useSessionGoal(sessionId ?? '', directory);
|
||||
const sessionStatus = useSessionStatus(sessionId ?? '', directory);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const handleToggleStatus = React.useCallback(async (nextStatus: 'active' | 'paused') => {
|
||||
if (!sessionId || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await setSessionGoalStatus(sessionId, directory, nextStatus);
|
||||
} catch (error) {
|
||||
console.warn('[session-goal] status change failed:', error);
|
||||
toast.error(t('chat.goal.toast.actionFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [sessionId, directory, busy, t]);
|
||||
|
||||
if (!sessionId || !enabled || !goal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Accounting only lands on idle ticks — hide the counter until there is a
|
||||
// real number (or a budget worth tracking against) instead of showing "0".
|
||||
const usage = goal.tokenBudget
|
||||
? t('chat.goal.usage.tokensWithBudget', {
|
||||
used: formatGoalTokens(goal.tokensUsed),
|
||||
budget: formatGoalTokens(goal.tokenBudget),
|
||||
})
|
||||
: (goal.tokensUsed > 0 ? t('chat.goal.usage.tokens', { used: formatGoalTokens(goal.tokensUsed) }) : null);
|
||||
|
||||
const pauseResume = goal.status === 'active'
|
||||
? { icon: 'pause' as const, labelKey: 'chat.goal.action.pause' as const, next: 'paused' as const }
|
||||
: (goal.status === 'paused' || goal.status === 'blocked' || goal.status === 'budgetLimited'
|
||||
? { icon: 'play' as const, labelKey: 'chat.goal.action.resume' as const, next: 'active' as const }
|
||||
: null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-w-0 items-center gap-2 rounded-lg border px-2 py-1',
|
||||
'border-[var(--interactive-border)]',
|
||||
className,
|
||||
)}
|
||||
aria-label={t('chat.goal.row.aria')}
|
||||
title={goal.objective}
|
||||
>
|
||||
<Icon name="target" className="h-3.5 w-3.5 flex-shrink-0" style={{ color: sessionGoalStatusColor[goal.status] }} aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate typography-meta text-foreground">
|
||||
{goal.note || goal.objective}
|
||||
</span>
|
||||
{goal.status === 'active' && (!sessionStatus || sessionStatus.type === 'idle') ? (
|
||||
// The agent stopped but the goal is still active: the server is
|
||||
// sitting out the quiet window and running the audit — show that
|
||||
// instead of a static "Active" that looks stuck.
|
||||
<span className="flex flex-shrink-0 items-center gap-1 typography-meta text-muted-foreground">
|
||||
<Icon name="loader-4" className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||
{t('chat.goal.status.evaluating')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-shrink-0 typography-meta text-muted-foreground">
|
||||
{t(sessionGoalStatusLabelKey[goal.status] as never)}
|
||||
</span>
|
||||
)}
|
||||
{usage ? (
|
||||
<span className="flex-shrink-0 typography-meta tabular-nums text-muted-foreground/70">
|
||||
{usage}
|
||||
</span>
|
||||
) : null}
|
||||
{pauseResume ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleToggleStatus(pauseResume.next)}
|
||||
disabled={busy}
|
||||
className="flex flex-shrink-0 cursor-pointer items-center gap-1 rounded px-1 py-0.5 typography-meta text-muted-foreground hover:bg-[var(--interactive-hover)] hover:text-foreground disabled:opacity-50"
|
||||
aria-label={t(pauseResume.labelKey)}
|
||||
>
|
||||
<Icon name={pauseResume.icon} className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{t(pauseResume.labelKey)}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SessionGoalRow.displayName = 'SessionGoalRow';
|
||||
@@ -76,7 +76,6 @@ export const iconSpriteData = {
|
||||
"download": `<path d="M3 19H21V21H3V19ZM13 13.1716L19.0711 7.1005L20.4853 8.51472L12 17L3.51472 8.51472L4.92893 7.1005L11 13.1716V2H13V13.1716Z" fill="currentColor"/>`,
|
||||
"drag-move-2": `<path d="M11 11V5.82843L9.17157 7.65685L7.75736 6.24264L12 2L16.2426 6.24264L14.8284 7.65685L13 5.82843V11H18.1716L16.3431 9.17157L17.7574 7.75736L22 12L17.7574 16.2426L16.3431 14.8284L18.1716 13H13V18.1716L14.8284 16.3431L16.2426 17.7574L12 22L7.75736 17.7574L9.17157 16.3431L11 18.1716V13H5.82843L7.65685 14.8284L6.24264 16.2426L2 12L6.24264 7.75736L7.65685 9.17157L5.82843 11H11Z" fill="currentColor"/>`,
|
||||
"draggable": `<path d="M8.5 7C9.32843 7 10 6.32843 10 5.5C10 4.67157 9.32843 4 8.5 4C7.67157 4 7 4.67157 7 5.5C7 6.32843 7.67157 7 8.5 7ZM8.5 13.5C9.32843 13.5 10 12.8284 10 12C10 11.1716 9.32843 10.5 8.5 10.5C7.67157 10.5 7 11.1716 7 12C7 12.8284 7.67157 13.5 8.5 13.5ZM10 18.5C10 19.3284 9.32843 20 8.5 20C7.67157 20 7 19.3284 7 18.5C7 17.6716 7.67157 17 8.5 17C9.32843 17 10 17.6716 10 18.5ZM15.5 7C16.3284 7 17 6.32843 17 5.5C17 4.67157 16.3284 4 15.5 4C14.6716 4 14 4.67157 14 5.5C14 6.32843 14.6716 7 15.5 7ZM17 12C17 12.8284 16.3284 13.5 15.5 13.5C14.6716 13.5 14 12.8284 14 12C14 11.1716 14.6716 10.5 15.5 10.5C16.3284 10.5 17 11.1716 17 12ZM15.5 20C16.3284 20 17 19.3284 17 18.5C17 17.6716 16.3284 17 15.5 17C14.6716 17 14 17.6716 14 18.5C14 19.3284 14.6716 20 15.5 20Z" fill="currentColor"/>`,
|
||||
"earth": `<path d="M6.23509 6.45329C4.85101 7.89148 4 9.84636 4 12C4 16.4183 7.58172 20 12 20C13.0808 20 14.1116 19.7857 15.0521 19.3972C15.1671 18.6467 14.9148 17.9266 14.8116 17.6746C14.582 17.115 13.8241 16.1582 12.5589 14.8308C12.2212 14.4758 12.2429 14.2035 12.3636 13.3943L12.3775 13.3029C12.4595 12.7486 12.5971 12.4209 14.4622 12.1248C15.4097 11.9746 15.6589 12.3533 16.0043 12.8777C16.0425 12.9358 16.0807 12.9928 16.1198 13.0499C16.4479 13.5297 16.691 13.6394 17.0582 13.8064C17.2227 13.881 17.428 13.9751 17.7031 14.1314C18.3551 14.504 18.3551 14.9247 18.3551 15.8472V15.9518C18.3551 16.3434 18.3168 16.6872 18.2566 16.9859C19.3478 15.6185 20 13.8854 20 12C20 8.70089 18.003 5.8682 15.1519 4.64482C14.5987 5.01813 13.8398 5.54726 13.575 5.91C13.4396 6.09538 13.2482 7.04166 12.6257 7.11976C12.4626 7.14023 12.2438 7.12589 12.012 7.11097C11.3905 7.07058 10.5402 7.01606 10.268 7.75495C10.0952 8.2232 10.0648 9.49445 10.6239 10.1543C10.7134 10.2597 10.7307 10.4547 10.6699 10.6735C10.59 10.9608 10.4286 11.1356 10.3783 11.1717C10.2819 11.1163 10.0896 10.8931 9.95938 10.7412C9.64554 10.3765 9.25405 9.92233 8.74797 9.78176C8.56395 9.73083 8.36166 9.68867 8.16548 9.64736C7.6164 9.53227 6.99443 9.40134 6.84992 9.09302C6.74442 8.8672 6.74488 8.55621 6.74529 8.22764C6.74529 7.8112 6.74529 7.34029 6.54129 6.88256C6.46246 6.70541 6.35689 6.56446 6.23509 6.45329ZM12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22Z" fill="currentColor"/>`,
|
||||
"edit-2": `<path d="M5 18.89H6.41421L15.7279 9.57627L14.3137 8.16206L5 17.4758V18.89ZM21 20.89H3V16.6473L16.435 3.21231C16.8256 2.82179 17.4587 2.82179 17.8492 3.21231L20.6777 6.04074C21.0682 6.43126 21.0682 7.06443 20.6777 7.45495L9.24264 18.89H21V20.89ZM15.7279 6.74785L17.1421 8.16206L18.5563 6.74785L17.1421 5.33363L15.7279 6.74785Z" fill="currentColor"/>`,
|
||||
"edit": `<path d="M6.41421 15.89L16.5563 5.74785L15.1421 4.33363L5 14.4758V15.89H6.41421ZM7.24264 17.89H3V13.6473L14.435 2.21231C14.8256 1.82179 15.4587 1.82179 15.8492 2.21231L18.6777 5.04074C19.0682 5.43126 19.0682 6.06443 18.6777 6.45495L7.24264 17.89ZM3 19.89H21V21.89H3V19.89Z" fill="currentColor"/>`,
|
||||
"emotion-happy": `<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM7 13H9C9 14.6569 10.3431 16 12 16C13.6569 16 15 14.6569 15 13H17C17 15.7614 14.7614 18 12 18C9.23858 18 7 15.7614 7 13ZM8 11C7.17157 11 6.5 10.3284 6.5 9.5C6.5 8.67157 7.17157 8 8 8C8.82843 8 9.5 8.67157 9.5 9.5C9.5 10.3284 8.82843 11 8 11ZM16 11C15.1716 11 14.5 10.3284 14.5 9.5C14.5 8.67157 15.1716 8 16 8C16.8284 8 17.5 8.67157 17.5 9.5C17.5 10.3284 16.8284 11 16 11Z" fill="currentColor"/>`,
|
||||
@@ -167,6 +166,7 @@ export const iconSpriteData = {
|
||||
"node-tree": `<path d="M10 2C10.5523 2 11 2.44772 11 3V7C11 7.55228 10.5523 8 10 8H8V10H13V9C13 8.44772 13.4477 8 14 8H20C20.5523 8 21 8.44772 21 9V13C21 13.5523 20.5523 14 20 14H14C13.4477 14 13 13.5523 13 13V12H8V18H13V17C13 16.4477 13.4477 16 14 16H20C20.5523 16 21 16.4477 21 17V21C21 21.5523 20.5523 22 20 22H14C13.4477 22 13 21.5523 13 21V20H7C6.44772 20 6 19.5523 6 19V8H4C3.44772 8 3 7.55228 3 7V3C3 2.44772 3.44772 2 4 2H10ZM19 18H15V20H19V18ZM19 10H15V12H19V10ZM9 4H5V6H9V4Z" fill="currentColor"/>`,
|
||||
"notification-3": `<path d="M20 17H22V19H2V17H4V10C4 5.58172 7.58172 2 12 2C16.4183 2 20 5.58172 20 10V17ZM18 17V10C18 6.68629 15.3137 4 12 4C8.68629 4 6 6.68629 6 10V17H18ZM9 21H15V23H9V21Z" fill="currentColor"/>`,
|
||||
"palette": `<path d="M12 2C17.5222 2 22 5.97778 22 10.8889C22 13.9556 19.5111 16.4444 16.4444 16.4444H14.4778C13.5556 16.4444 12.8111 17.1889 12.8111 18.1111C12.8111 18.5333 12.9778 18.9222 13.2333 19.2111C13.5 19.5111 13.6667 19.9 13.6667 20.3333C13.6667 21.2556 12.9 22 12 22C6.47778 22 2 17.5222 2 12C2 6.47778 6.47778 2 12 2ZM10.8111 18.1111C10.8111 16.0843 12.451 14.4444 14.4778 14.4444H16.4444C18.4065 14.4444 20 12.851 20 10.8889C20 7.1392 16.4677 4 12 4C7.58235 4 4 7.58235 4 12C4 16.19 7.2226 19.6285 11.324 19.9718C10.9948 19.4168 10.8111 18.7761 10.8111 18.1111ZM7.5 12C6.67157 12 6 11.3284 6 10.5C6 9.67157 6.67157 9 7.5 9C8.32843 9 9 9.67157 9 10.5C9 11.3284 8.32843 12 7.5 12ZM16.5 12C15.6716 12 15 11.3284 15 10.5C15 9.67157 15.6716 9 16.5 9C17.3284 9 18 9.67157 18 10.5C18 11.3284 17.3284 12 16.5 12ZM12 9C11.1716 9 10.5 8.32843 10.5 7.5C10.5 6.67157 11.1716 6 12 6C12.8284 6 13.5 6.67157 13.5 7.5C13.5 8.32843 12.8284 9 12 9Z" fill="currentColor"/>`,
|
||||
"pause": `<path d="M6 5H8V19H6V5ZM16 5H18V19H16V5Z" fill="currentColor"/>`,
|
||||
"pencil-ai-2": `<path d="M18.5293 15.3193C18.7058 14.8934 19.2942 14.8934 19.4707 15.3193L19.7236 15.9307C20.1556 16.9735 20.9615 17.8062 21.9746 18.2568L22.6914 18.5762C23.1022 18.7589 23.1022 19.3564 22.6914 19.5391L21.9326 19.877C20.9449 20.3163 20.1534 21.1194 19.7139 22.1279L19.4668 22.6934C19.2863 23.1075 18.7136 23.1075 18.5332 22.6934L18.2861 22.1279C17.8466 21.1194 17.0551 20.3163 16.0674 19.877L15.3076 19.5391C14.8974 19.3562 14.8974 18.759 15.3076 18.5762L16.0254 18.2568C17.0385 17.8062 17.8444 16.9735 18.2764 15.9307L18.5293 15.3193ZM16.4346 3.21193C16.8251 2.82141 17.4591 2.82141 17.8496 3.21193L20.6777 6.04103C21.0681 6.43157 21.0682 7.06464 20.6777 7.45509L7.24219 20.8897H3V16.6475L16.4346 3.21193ZM5 17.4756V18.8897H6.41406L15.7275 9.57618L14.3135 8.16212L5 17.4756ZM15.7275 6.74806L17.1426 8.16212L18.5566 6.74806L17.1426 5.334L15.7275 6.74806Z" fill="currentColor"/>`,
|
||||
"pencil-ai": `<path d="M16.4356 3.21188C16.8261 2.82185 17.4592 2.82157 17.8496 3.21188L20.6777 6.04099C21.0681 6.43152 21.0682 7.06457 20.6777 7.45505L7.2422 20.8896H3.00001V16.6475L16.4356 3.21188ZM5.00001 17.4756V18.8896H6.41407L15.7276 9.57615L14.3135 8.16208L5.00001 17.4756ZM4.5293 1.3193C4.70583 0.893505 5.29418 0.893508 5.47071 1.3193L5.72364 1.93063C6.15555 2.97342 6.96155 3.80613 7.97462 4.2568L8.69239 4.57614C9.10267 4.75896 9.10262 5.35616 8.69239 5.53903L7.93263 5.87692C6.94497 6.3162 6.15339 7.11943 5.71387 8.1279L5.4668 8.69334C5.28636 9.10747 4.71366 9.10747 4.53321 8.69334L4.28614 8.1279C3.84661 7.11943 3.05506 6.3162 2.06739 5.87692L1.30762 5.53903C0.897483 5.35617 0.897435 4.75896 1.30762 4.57614L2.0254 4.2568C3.03845 3.80614 3.84446 2.97344 4.27637 1.93063L4.5293 1.3193ZM15.7276 6.74802L17.1426 8.16208L18.5567 6.74802L17.1426 5.33395L15.7276 6.74802Z" fill="currentColor"/>`,
|
||||
"pencil": `<path d="M15.7279 9.57627L14.3137 8.16206L5 17.4758V18.89H6.41421L15.7279 9.57627ZM17.1421 8.16206L18.5563 6.74785L17.1421 5.33363L15.7279 6.74785L17.1421 8.16206ZM7.24264 20.89H3V16.6473L16.435 3.21231C16.8256 2.82179 17.4587 2.82179 17.8492 3.21231L20.6777 6.04074C21.0682 6.43126 21.0682 7.06443 20.6777 7.45495L7.24264 20.89Z" fill="currentColor"/>`,
|
||||
@@ -214,6 +214,8 @@ export const iconSpriteData = {
|
||||
"stop": `<path d="M7 7V17H17V7H7ZM6 5H18C18.5523 5 19 5.44772 19 6V18C19 18.5523 18.5523 19 18 19H6C5.44772 19 5 18.5523 5 18V6C5 5.44772 5.44772 5 6 5Z" fill="currentColor"/>`,
|
||||
"subtract": `<path d="M5 11V13H19V11H5Z" fill="currentColor"/>`,
|
||||
"survey": `<path d="M17 2V4H20.0066C20.5552 4 21 4.44495 21 4.9934V21.0066C21 21.5552 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5551 3 21.0066V4.9934C3 4.44476 3.44495 4 3.9934 4H7V2H17ZM7 6H5V20H19V6H17V8H7V6ZM9 16V18H7V16H9ZM9 13V15H7V13H9ZM9 10V12H7V10H9ZM15 4H9V6H15V4Z" fill="currentColor"/>`,
|
||||
"target-fill": `<path d="M12 2C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 6C12.5523 6 13 6.44772 13 7C13 7.55228 12.5523 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6ZM18.5713 2.10059C18.8474 2.1006 19.0712 2.32449 19.0713 2.60059V4.42969C19.0716 4.70553 19.2954 4.92866 19.5713 4.92871H21.3994C21.6754 4.92871 21.8992 5.15275 21.8994 5.42871V6.34375L20.0107 8.23242C19.6358 8.60719 19.1268 8.81824 18.5967 8.81836H16.5967L12.707 12.707C12.3165 13.0974 11.6835 13.0975 11.293 12.707C10.9027 12.3165 10.9026 11.6834 11.293 11.293L15.1826 7.4043V5.4043C15.1826 4.87411 15.3928 4.36526 15.7676 3.99023L17.6572 2.10059H18.5713Z" fill="currentColor"/>`,
|
||||
"target": `<path d="M12 1.99999C12.5523 1.99999 13 2.4477 13 2.99999C12.9999 3.55224 12.5522 3.99999 12 3.99999C7.58172 3.99999 4 7.58171 4 12C4.00004 16.4182 7.58174 20 12 20C16.4182 20 19.9999 16.4182 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C21.9999 17.5228 17.5228 22 12 22C6.47717 22 2.00004 17.5228 2 12C2 6.47714 6.47715 1.99999 12 1.99999ZM12 5.99999C12.5523 5.99999 13 6.4477 13 6.99999C12.9999 7.55224 12.5522 7.99999 12 7.99999C9.79085 7.99999 7.99999 9.79085 7.99999 12C8.00004 14.2091 9.79088 16 12 16C14.2091 16 15.9999 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C17.9999 15.3137 15.3137 18 12 18C8.68631 18 6.00004 15.3137 6 12C6 8.68628 8.68629 5.99999 12 5.99999ZM17.6562 2.10057C18.0468 1.71005 18.6807 1.71005 19.0713 2.10057C19.4614 2.49105 19.4615 3.12419 19.0713 3.51463L18.3633 4.22069L18.3642 4.22167C17.9737 4.61219 17.9737 5.2452 18.3642 5.63573C18.7548 6.02612 19.3878 6.02621 19.7783 5.63573L20.4853 4.9287C20.8759 4.53839 21.5089 4.53826 21.8994 4.9287C22.2899 5.31915 22.2897 5.95222 21.8994 6.34276L19.7783 8.46483C19.5909 8.65223 19.3363 8.75671 19.0713 8.75682H16.6572L12.707 12.707C12.3165 13.0974 11.6834 13.0974 11.293 12.707C10.9025 12.3165 10.9026 11.6835 11.293 11.293L15.2422 7.34374V4.9287C15.2422 4.66356 15.3477 4.40916 15.5351 4.22167L17.6562 2.10057Z" fill="currentColor"/>`,
|
||||
"task": `<path d="M19 4H5V20H19V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H19.9997C20.5519 2 20.9996 2.44772 20.9997 3L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11.2929 13.1213L15.5355 8.87868L16.9497 10.2929L11.2929 15.9497L7.40381 12.0607L8.81802 10.6464L11.2929 13.1213Z" fill="currentColor"/>`,
|
||||
"terminal-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z" fill="currentColor"/>`,
|
||||
"terminal": `<path d="M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -145,7 +145,7 @@ const VisualSectionContent: React.FC = () => {
|
||||
|
||||
// Chat section: User message rendering, Diff layout, Mobile status bar, Show reasoning traces, Follow-up behavior, Persist draft
|
||||
const ChatSectionContent: React.FC = () => {
|
||||
return <OpenChamberVisualSettings visibleSettings={['sessionAssist', 'chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'collapsibleUserMessages', 'stickyUserHeader', 'wideChatLayout', 'codeBlockLineWrap', 'splitAssistantMessageActions', 'diffLayout', 'dotfiles', 'fileViewerPreview', 'followUpBehavior', 'persistDraft', 'inputSpellcheck']} />;
|
||||
return <OpenChamberVisualSettings visibleSettings={['sessionGoal', 'sessionAssist', 'chatRenderMode', 'messageTransport', 'activityRenderMode', 'userMessageRendering', 'mermaidRendering', 'reasoning', 'showToolFileIcons', 'showTurnChangedFiles', 'expandedTools', 'collapsibleUserMessages', 'stickyUserHeader', 'wideChatLayout', 'codeBlockLineWrap', 'splitAssistantMessageActions', 'diffLayout', 'dotfiles', 'fileViewerPreview', 'followUpBehavior', 'persistDraft', 'inputSpellcheck']} />;
|
||||
};
|
||||
|
||||
// Sessions section: Default model & agent, Session retention
|
||||
|
||||
@@ -245,7 +245,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
type VisibleSetting = 'sessionAssist' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
|
||||
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
|
||||
|
||||
interface OpenChamberVisualSettingsProps {
|
||||
/** Which settings to show. If undefined, shows all. */
|
||||
@@ -263,6 +263,12 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const sessionSuggestionEnabled = useUIStore(state => state.sessionSuggestionEnabled);
|
||||
const setSessionRecapEnabled = useUIStore(state => state.setSessionRecapEnabled);
|
||||
const setSessionSuggestionEnabled = useUIStore(state => state.setSessionSuggestionEnabled);
|
||||
const sessionGoalEnabled = useUIStore(state => state.sessionGoalEnabled);
|
||||
const setSessionGoalEnabled = useUIStore(state => state.setSessionGoalEnabled);
|
||||
const sessionGoalDefaultBudgetEnabled = useUIStore(state => state.sessionGoalDefaultBudgetEnabled);
|
||||
const setSessionGoalDefaultBudgetEnabled = useUIStore(state => state.setSessionGoalDefaultBudgetEnabled);
|
||||
const sessionGoalDefaultBudget = useUIStore(state => state.sessionGoalDefaultBudget);
|
||||
const setSessionGoalDefaultBudget = useUIStore(state => state.setSessionGoalDefaultBudget);
|
||||
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
|
||||
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
|
||||
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
|
||||
@@ -1813,6 +1819,81 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The goal loop runs in the web server — VS Code only renders
|
||||
goal state, so the settings section is hidden there too. */}
|
||||
{shouldShow('sessionGoal') && !isVSCode && (
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">{t('settings.openchamber.visual.goal.sectionTitle')}</h3>
|
||||
</div>
|
||||
<section className="p-2 space-y-0.5">
|
||||
<div
|
||||
data-settings-item="chat.session-goal"
|
||||
className="group flex cursor-pointer items-center gap-2 py-0.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={sessionGoalEnabled}
|
||||
onClick={() => setSessionGoalEnabled(!sessionGoalEnabled)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setSessionGoalEnabled(!sessionGoalEnabled);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={sessionGoalEnabled}
|
||||
onChange={setSessionGoalEnabled}
|
||||
ariaLabel={t('settings.openchamber.visual.field.sessionGoalAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.sessionGoal')}</span>
|
||||
</div>
|
||||
<div
|
||||
data-settings-item="chat.session-goal-budget"
|
||||
className="flex items-center gap-2 py-0.5"
|
||||
>
|
||||
<div
|
||||
className={cn('flex items-center gap-2', sessionGoalEnabled ? 'cursor-pointer' : 'opacity-50')}
|
||||
role="button"
|
||||
tabIndex={sessionGoalEnabled ? 0 : -1}
|
||||
aria-pressed={sessionGoalDefaultBudgetEnabled}
|
||||
onClick={() => {
|
||||
if (sessionGoalEnabled) setSessionGoalDefaultBudgetEnabled(!sessionGoalDefaultBudgetEnabled);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (sessionGoalEnabled && (event.key === ' ' || event.key === 'Enter')) {
|
||||
event.preventDefault();
|
||||
setSessionGoalDefaultBudgetEnabled(!sessionGoalDefaultBudgetEnabled);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={sessionGoalDefaultBudgetEnabled}
|
||||
onChange={setSessionGoalDefaultBudgetEnabled}
|
||||
disabled={!sessionGoalEnabled}
|
||||
ariaLabel={t('settings.openchamber.visual.goal.budgetAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.goal.budgetLabel')}</span>
|
||||
</div>
|
||||
{sessionGoalEnabled && sessionGoalDefaultBudgetEnabled ? (
|
||||
<NumberInput
|
||||
value={sessionGoalDefaultBudget}
|
||||
onValueChange={(value) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
setSessionGoalDefaultBudget(Math.floor(value));
|
||||
}
|
||||
}}
|
||||
min={1000}
|
||||
max={100000000}
|
||||
step={50000}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground/70 px-0.5 pt-0.5">{t('settings.openchamber.visual.goal.description')}</p>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(shouldShow('sessionAssist') || shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || shouldShow('wideChatLayout') || shouldShow('codeBlockLineWrap') || shouldShow('splitAssistantMessageActions') || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('reasoning')) && (
|
||||
<section className="p-2 space-y-0.5">
|
||||
{shouldShow('sessionAssist') && (
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ThinkingPill } from '@/components/session/ThinkingPill';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useAgentsStore } from '@/stores/useAgentsStore';
|
||||
import { isPrimaryMode } from '@/components/chat/mobileControlsUtils';
|
||||
import { EXECUTION_FORK_DEFAULT_INSTRUCTIONS } from '@/lib/messages/executionMeta';
|
||||
import { EXECUTION_FORK_DEFAULT_INSTRUCTIONS, EXECUTION_FORK_GOAL_INSTRUCTIONS } from '@/lib/messages/executionMeta';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
|
||||
@@ -25,6 +25,7 @@ export type ForkSessionExecution = {
|
||||
agent: string;
|
||||
instructions: string;
|
||||
createWorktree?: boolean;
|
||||
runAsGoal?: boolean;
|
||||
};
|
||||
|
||||
type ForkSessionDialogProps = {
|
||||
@@ -54,7 +55,22 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
|
||||
const [agent, setAgent] = React.useState(currentAgentName);
|
||||
const [instructions, setInstructions] = React.useState(EXECUTION_FORK_DEFAULT_INSTRUCTIONS);
|
||||
const [createWorktree, setCreateWorktree] = React.useState(false);
|
||||
const [runAsGoal, setRunAsGoal] = React.useState(false);
|
||||
const showCreateWorktree = React.useMemo(() => !isVSCodeRuntime(), []);
|
||||
// The goal loop lives in the web server; VS Code only renders goal state.
|
||||
const showRunAsGoal = React.useMemo(() => !isVSCodeRuntime(), []);
|
||||
|
||||
// Toggling goal mode swaps the prefilled instructions between the
|
||||
// report-back default and the assertive execute-to-completion variant —
|
||||
// but never clobbers text the user has edited.
|
||||
const handleToggleRunAsGoal = React.useCallback((next: boolean) => {
|
||||
setRunAsGoal(next);
|
||||
setInstructions((current) => {
|
||||
if (next && current === EXECUTION_FORK_DEFAULT_INSTRUCTIONS) return EXECUTION_FORK_GOAL_INSTRUCTIONS;
|
||||
if (!next && current === EXECUTION_FORK_GOAL_INSTRUCTIONS) return EXECUTION_FORK_DEFAULT_INSTRUCTIONS;
|
||||
return current;
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -75,6 +91,7 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
|
||||
setAgent(config.currentAgentName || '');
|
||||
setInstructions(EXECUTION_FORK_DEFAULT_INSTRUCTIONS);
|
||||
setCreateWorktree(false);
|
||||
setRunAsGoal(false);
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -119,8 +136,9 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
|
||||
agent,
|
||||
instructions,
|
||||
createWorktree: showCreateWorktree && createWorktree,
|
||||
runAsGoal: showRunAsGoal && runAsGoal,
|
||||
});
|
||||
}, [canConfirm, submitting, onConfirm, providerID, modelID, variant, agent, instructions, showCreateWorktree, createWorktree]);
|
||||
}, [canConfirm, submitting, onConfirm, providerID, modelID, variant, agent, instructions, showCreateWorktree, createWorktree, showRunAsGoal, runAsGoal]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -186,25 +204,45 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-3 ${showCreateWorktree ? 'justify-between' : 'justify-end'}`}>
|
||||
{showCreateWorktree ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Checkbox
|
||||
checked={createWorktree}
|
||||
onChange={setCreateWorktree}
|
||||
disabled={submitting}
|
||||
ariaLabel={t('chat.messageBody.forkDialog.createWorktree')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="truncate typography-ui-label text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
onClick={() => setCreateWorktree((value) => !value)}
|
||||
>
|
||||
{t('chat.messageBody.forkDialog.createWorktree')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`flex items-center gap-3 ${showCreateWorktree || showRunAsGoal ? 'justify-between' : 'justify-end'}`}>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1">
|
||||
{showCreateWorktree ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Checkbox
|
||||
checked={createWorktree}
|
||||
onChange={setCreateWorktree}
|
||||
disabled={submitting}
|
||||
ariaLabel={t('chat.messageBody.forkDialog.createWorktree')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="truncate typography-ui-label text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
onClick={() => setCreateWorktree((value) => !value)}
|
||||
>
|
||||
{t('chat.messageBody.forkDialog.createWorktree')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{showRunAsGoal ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Checkbox
|
||||
checked={runAsGoal}
|
||||
onChange={handleToggleRunAsGoal}
|
||||
disabled={submitting}
|
||||
ariaLabel={t('sessions.scheduledTasks.editor.goal.aria')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="truncate typography-ui-label text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
onClick={() => handleToggleRunAsGoal(!runAsGoal)}
|
||||
>
|
||||
{t('sessions.scheduledTasks.editor.goal.label')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
{t('rightSidebar.contextNotesTodo.sendDialog.actions.cancel')}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { toast } from '@/components/ui';
|
||||
@@ -466,6 +467,8 @@ type ScheduledTaskDraft = {
|
||||
modelID: string;
|
||||
variant: string;
|
||||
agent: string;
|
||||
goalEnabled: boolean;
|
||||
goalTokenBudget: number | null;
|
||||
};
|
||||
state?: ScheduledTask['state'];
|
||||
};
|
||||
@@ -515,6 +518,8 @@ const toDraft = (
|
||||
modelID: defaults.modelID,
|
||||
variant: defaults.variant,
|
||||
agent: defaults.agent,
|
||||
goalEnabled: false,
|
||||
goalTokenBudget: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -548,6 +553,10 @@ const toDraft = (
|
||||
modelID: task.execution.modelID,
|
||||
variant: task.execution.variant || '',
|
||||
agent: task.execution.agent || '',
|
||||
goalEnabled: task.execution.goalEnabled === true,
|
||||
goalTokenBudget: typeof task.execution.goalTokenBudget === 'number' && task.execution.goalTokenBudget > 0
|
||||
? task.execution.goalTokenBudget
|
||||
: null,
|
||||
},
|
||||
state: task.state,
|
||||
};
|
||||
@@ -1153,6 +1162,10 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
modelID: draft.execution.modelID,
|
||||
...(draft.execution.variant.trim() ? { variant: draft.execution.variant.trim() } : {}),
|
||||
...(draft.execution.agent.trim() ? { agent: draft.execution.agent.trim() } : {}),
|
||||
...(draft.execution.goalEnabled ? { goalEnabled: true } : {}),
|
||||
...(draft.execution.goalEnabled && draft.execution.goalTokenBudget
|
||||
? { goalTokenBudget: draft.execution.goalTokenBudget }
|
||||
: {}),
|
||||
},
|
||||
...(draft.state ? { state: draft.state } : {}),
|
||||
};
|
||||
@@ -1608,6 +1621,49 @@ export function ScheduledTaskEditorDialog(props: {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2">
|
||||
<Checkbox
|
||||
checked={draft.execution.goalEnabled}
|
||||
onChange={(goalEnabled) => setDraft((prev) => ({
|
||||
...prev,
|
||||
execution: { ...prev.execution, goalEnabled },
|
||||
}))}
|
||||
ariaLabel={t('sessions.scheduledTasks.editor.goal.aria')}
|
||||
/>
|
||||
<span className="typography-meta">{t('sessions.scheduledTasks.editor.goal.label')}</span>
|
||||
</label>
|
||||
{draft.execution.goalEnabled ? (
|
||||
<label className="inline-flex cursor-pointer items-center gap-2">
|
||||
<Checkbox
|
||||
checked={draft.execution.goalTokenBudget !== null}
|
||||
onChange={(hasBudget) => setDraft((prev) => ({
|
||||
...prev,
|
||||
execution: { ...prev.execution, goalTokenBudget: hasBudget ? 200_000 : null },
|
||||
}))}
|
||||
ariaLabel={t('sessions.scheduledTasks.editor.goal.budgetAria')}
|
||||
/>
|
||||
<span className="typography-meta">{t('sessions.scheduledTasks.editor.goal.budgetLabel')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
{draft.execution.goalEnabled && draft.execution.goalTokenBudget !== null ? (
|
||||
<NumberInput
|
||||
value={draft.execution.goalTokenBudget}
|
||||
onValueChange={(value) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
execution: { ...prev.execution, goalTokenBudget: Math.floor(value) },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
min={1000}
|
||||
max={100000000}
|
||||
step={50000}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||
import { AgentSelector } from '@/components/sections/commands/AgentSelector';
|
||||
import { ThinkingPill } from '@/components/session/ThinkingPill';
|
||||
@@ -21,6 +23,7 @@ export type TodoSendExecution = {
|
||||
modelID: string;
|
||||
variant: string;
|
||||
agent: string;
|
||||
runAsGoal?: boolean;
|
||||
};
|
||||
|
||||
type TodoSendDialogProps = {
|
||||
@@ -29,6 +32,8 @@ type TodoSendDialogProps = {
|
||||
target: TodoSendTarget;
|
||||
projectDirectory: string | null;
|
||||
submitting?: boolean;
|
||||
/** Offer a "Run as goal" checkbox (hidden in VS Code, where the loop does not run). */
|
||||
allowRunAsGoal?: boolean;
|
||||
onConfirm: (execution: TodoSendExecution) => Promise<void> | void;
|
||||
};
|
||||
|
||||
@@ -46,7 +51,8 @@ const getInitialExecution = (params: {
|
||||
|
||||
export function TodoSendDialog(props: TodoSendDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { open, onOpenChange, target, projectDirectory, submitting = false, onConfirm } = props;
|
||||
const { open, onOpenChange, target, projectDirectory, submitting = false, allowRunAsGoal = false, onConfirm } = props;
|
||||
const showRunAsGoal = allowRunAsGoal && !isVSCodeRuntime();
|
||||
|
||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||
const loadConfigAgents = useConfigStore((state) => state.loadAgents);
|
||||
@@ -177,7 +183,26 @@ export function TodoSendDialog(props: TodoSendDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className={`flex items-center gap-3 ${showRunAsGoal ? 'justify-between' : 'justify-end'}`}>
|
||||
{showRunAsGoal ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Checkbox
|
||||
checked={execution.runAsGoal === true}
|
||||
onChange={(runAsGoal: boolean) => setExecution((prev) => ({ ...prev, runAsGoal }))}
|
||||
disabled={submitting}
|
||||
ariaLabel={t('sessions.scheduledTasks.editor.goal.aria')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="truncate typography-ui-label text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
onClick={() => setExecution((prev) => ({ ...prev, runAsGoal: prev.runAsGoal !== true }))}
|
||||
>
|
||||
{t('sessions.scheduledTasks.editor.goal.label')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
{t('rightSidebar.contextNotesTodo.sendDialog.actions.cancel')}
|
||||
</Button>
|
||||
@@ -186,6 +211,7 @@ export function TodoSendDialog(props: TodoSendDialogProps) {
|
||||
? t('rightSidebar.contextNotesTodo.sendDialog.actions.sending')
|
||||
: t('rightSidebar.contextNotesTodo.sendDialog.actions.send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -34,6 +34,8 @@ import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useShiftKeyHeld } from '@/hooks/useShiftKeyHeld';
|
||||
import { getSessionGoal } from '@/lib/sessionGoalMetadata';
|
||||
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
|
||||
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
|
||||
@@ -353,6 +355,16 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
);
|
||||
const sessionStatus = useGlobalSessionStatus(session.id);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
||||
const sessionGoal = getSessionGoal(resolvedSession);
|
||||
const sessionGoalGlyph = sessionGoal ? (
|
||||
<span
|
||||
className="inline-flex flex-shrink-0 items-center"
|
||||
title={t(sessionGoalStatusLabelKey[sessionGoal.status] as never)}
|
||||
aria-label={t(sessionGoalStatusLabelKey[sessionGoal.status] as never)}
|
||||
>
|
||||
<Icon name="target" className="h-3 w-3" style={{ color: sessionGoalStatusColor[sessionGoal.status] }} />
|
||||
</span>
|
||||
) : null;
|
||||
const isActive = currentSessionId === session.id;
|
||||
const sessionTitle = resolvedSession.title || t('sessions.sidebar.session.untitled');
|
||||
const hasChildren = node.children.length > 0;
|
||||
@@ -1030,15 +1042,21 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
>
|
||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
||||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
{alwaysShowActions ? <span className="ml-2 flex-shrink-0 text-[0.72rem] text-muted-foreground/75">{sessionCompactUpdatedLabel}</span> : null}
|
||||
{alwaysShowActions ? (
|
||||
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 text-[0.72rem] text-muted-foreground/75">
|
||||
{sessionGoalGlyph}
|
||||
{sessionCompactUpdatedLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{!alwaysShowActions ? (
|
||||
<div className="relative ml-1 flex h-4 min-w-4 flex-shrink-0 items-center justify-end">
|
||||
<span className={cn(
|
||||
'whitespace-nowrap text-right text-[0.72rem] text-muted-foreground/75 transition-opacity duration-150',
|
||||
'inline-flex items-center gap-1 whitespace-nowrap text-right text-[0.72rem] text-muted-foreground/75 transition-opacity duration-150',
|
||||
isSessionMenuOpen
|
||||
? 'opacity-0'
|
||||
: hideOnHoverClass,
|
||||
)}>
|
||||
{sessionGoalGlyph}
|
||||
{sessionCompactUpdatedLabel}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1105,6 +1123,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
{!isMinimalMode ? (
|
||||
<div className="flex items-center justify-between gap-3 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||||
<div className={cn('flex min-w-0 items-center gap-1.5 overflow-hidden', metadataSubsessionChevron && hasChildren ? 'pl-4' : '')}>
|
||||
{sessionGoalGlyph}
|
||||
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
|
||||
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
|
||||
{hasSecondaryBranchLabel ? <span className="inline-flex min-w-0 items-center gap-0.5"><Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" /><span className="truncate">{secondaryMeta?.branchLabel}</span></span> : null}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
@@ -594,6 +595,19 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
|
||||
}
|
||||
|
||||
setCurrentSession(sessionId, directoryHint);
|
||||
// "Run as goal" rides the same arm mechanism as the composer target
|
||||
// button; set explicitly either way so a stray armed flag cannot
|
||||
// leak into a non-goal plan send. The objective override carries the
|
||||
// actual plan content — "Implement this plan: X" alone would give
|
||||
// the progress audit nothing to judge against.
|
||||
const goalObjective = execution.runAsGoal === true
|
||||
? [
|
||||
`Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`,
|
||||
'',
|
||||
content,
|
||||
].join('\n')
|
||||
: null;
|
||||
useSessionGoalArmStore.getState().setArmed(execution.runAsGoal === true, goalObjective);
|
||||
await sendMessage(
|
||||
visiblePrompt,
|
||||
execution.providerID,
|
||||
@@ -610,7 +624,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
|
||||
setIsPlanSendSubmitting(false);
|
||||
}
|
||||
},
|
||||
[canCreateWorktree, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession]
|
||||
[canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession]
|
||||
);
|
||||
|
||||
const blockWidgets = React.useMemo(() => {
|
||||
@@ -778,6 +792,7 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null }) => {
|
||||
target={pendingPlanSend?.target ?? 'session'}
|
||||
projectDirectory={currentProjectRef?.path ?? null}
|
||||
submitting={isPlanSendSubmitting}
|
||||
allowRunAsGoal
|
||||
onConfirm={handleConfirmPlanSend}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user