Files
openchamber/packages/ui/src/components/chat/SessionGoalRow.tsx
T
Bohdan Triapitsyn c9ac8676e7 feat: file-backed goal objectives + compaction accounting fixes
Compaction fixes (observed in a real long run):
- the summary message's zeroed tokens froze the goal counter at its
  pre-compaction value; segments now close with the previously displayed
  total as a continuity floor
- audits and continuations after a summary tail now take execution params
  (provider/model/agent/variant) from the newest non-summary assistant
  turn instead of inheriting agent 'compaction' and the summarize model

File-backed objectives:
- the objective text lives in <data-dir>/goals/<sessionId>.md, keyed by
  session id (one goal per session, a new goal overwrites the file);
  metadata carries only an objectiveFile flag so session.updated fanout
  stays light, and never a path — ids are pattern-validated before any
  filesystem access
- limit raised to 5000 chars, no snapshot field: the UI fetches content
  via PUT/GET/DELETE /api/goals/objective/:sessionId (behind the blanket
  /api auth gate), writes the file before stamping metadata, and falls
  back to an inline objective when the write fails
- the loop reads the file fresh on every tick, so objectives are
  live-editable mid-goal; a missing file falls back to the inline text
- scheduled goal tasks write the objective file server-side; VS Code
  degrades to the audit note (route unavailable there by design)
2026-07-12 02:49:56 +03:00

110 lines
4.7 KiB
TypeScript

import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { useSessionStatus } from '@/sync/sync-context';
import { useGoalObjectiveContent, 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 objectiveContent = useGoalObjectiveContent(sessionId ?? '', goal);
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={objectiveContent ?? undefined}
>
<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 || objectiveContent || ''}
</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';