diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 7fb88a6e83..21d40caa0f 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -4957,15 +4957,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, workspace_path: &Path, ) -> OpenBitFunResult<()> { - let _goal_guard = self.lock_thread_goal_operation(session_id).await; + let goal_guard = self.lock_thread_goal_operation(session_id).await; let storage_path = self .resolve_thread_goal_storage_path(session_id, workspace_path) .await?; + // Dropping the goal drops the work it started: capture its turn before the + // clear releases the goal-to-turn binding. + let goal_turn = self.goal_driven_turn_id(session_id); self.thread_goal_store() .clear_thread_goal(session_id, storage_path.as_path()) .await?; self.thread_goal_runtime(session_id).clear_active_goal(None); self.emit_thread_goal_updated(session_id, None).await; + drop(goal_guard); + if let Some(turn_id) = goal_turn { + self.stop_goal_driven_turn(session_id, &turn_id).await; + } Ok(()) } @@ -5005,19 +5012,18 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "cannot edit goal for session {session_id}: no goal exists" )) })?; - let status = match existing.status { - ThreadGoalStatus::BudgetLimited | ThreadGoalStatus::Complete => { - Some(ThreadGoalStatus::Active) - } - _ => None, - }; + // Editing the objective is an explicit goal action, not a status edit: the + // user is telling the goal what to work on next, so it runs again instead + // of staying parked on the status the edit was made from. A goal that was + // paused, blocked or over quota therefore comes back as active. + let resuming = thread_goal_status_is_resumable(existing.status); let result = self .thread_goal_store() .set_thread_goal( session_id, storage_path.as_path(), Some(objective), - status, + Some(ThreadGoalStatus::Active), None, false, ) @@ -5029,9 +5035,24 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.emit_thread_goal_updated(session_id, Some(result.goal.clone())) .await; drop(goal_guard); - if objective_changed && result.goal.is_active() { - self.apply_objective_updated_steering(session_id, &result.goal) - .await; + // Saving the same text through the edit dialog is still the user asking for + // this goal to run, so a restarting edit delivers its steering either way. + if result.goal.is_active() && (objective_changed || resuming) { + if resuming { + // The edit restarts the goal, so it has to retire what stopped it: + // until the interrupted turn that paused the goal is abandoned, the + // objective steering below is parked and the active goal never moves. + self.release_superseded_interrupted_turn(session_id).await; + clear_thread_goal_continuation_abort(session_id); + } + // The restart is handed over in the background like resume: an idle + // session makes this steering admit the goal's next turn, and the user is + // waiting on the edit dialog, not on that turn being admitted. + self.schedule_thread_goal_steering( + session_id, + &result.goal, + AgentThreadGoalDeliveryKind::ObjectiveUpdated, + ); } Ok(result.goal) } @@ -5194,6 +5215,13 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet && previous .as_ref() .is_some_and(|goal| thread_goal_status_is_resumable(goal.status)); + // Pausing has to stop the goal's turn. The status write below drops the + // goal-to-turn binding, so capture the turn it currently owns first. + let paused_goal_turn = if status == ThreadGoalStatus::Paused { + self.goal_driven_turn_id(session_id) + } else { + None + }; let result = self .thread_goal_store() .set_thread_goal( @@ -5213,9 +5241,19 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.emit_thread_goal_updated(session_id, Some(result.goal.clone())) .await; drop(goal_guard); + if let Some(turn_id) = paused_goal_turn { + self.stop_goal_driven_turn(session_id, &turn_id).await; + } if resuming && result.goal.is_active() { + // Resuming is explicit too: the interruption that paused the goal must + // not keep parking the steering that starts its next turn. + self.release_superseded_interrupted_turn(session_id).await; clear_thread_goal_continuation_abort(session_id); - self.schedule_thread_goal_resumed_steering(session_id, &result.goal); + self.schedule_thread_goal_steering( + session_id, + &result.goal, + AgentThreadGoalDeliveryKind::Resumed, + ); } Ok(result.goal) } @@ -5258,10 +5296,117 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } - fn schedule_thread_goal_resumed_steering(&self, session_id: &str, goal: &ThreadGoal) { + /// The in-flight turn that is working toward the session's thread goal. + /// + /// The goal runtime only binds a goal to a turn while that goal is active, so + /// a bound turn that is also the session's current turn is exactly the work + /// the goal owns. Everything else in the session belongs to the user. + fn goal_driven_turn_id(&self, session_id: &str) -> Option { + let (turn_id, _) = self.thread_goal_runtime(session_id).current_turn_usage()?; + if turn_id.is_empty() { + return None; + } + let session = self.session_manager.get_session(session_id)?; + match session.state { + SessionState::Processing { + current_turn_id, .. + } if current_turn_id == turn_id => Some(turn_id), + _ => None, + } + } + + /// Stop the turn that is working toward a thread goal. + /// + /// Pausing or dropping a goal has to stop the work it started: the goal's own + /// turn keeps issuing rounds on the model and calling tools long after the + /// goal stopped being active, which reads as a paused or deleted goal that + /// keeps running. Only the goal's turn is stopped; an unrelated user turn in + /// the same session keeps running. + async fn stop_goal_driven_turn(&self, session_id: &str, turn_id: &str) { + info!( + "Stopping thread goal turn: session_id={}, turn_id={}", + session_id, turn_id + ); + // `Cancelled` is the disposition for work the user stopped. It also ends + // the recoverable-interruption fence a goal turn would otherwise leave + // behind, which is what lets the next explicit goal activation start. + // + // Stopping a turn can pause the goal it belonged to, so this call reaches + // back into the goal status write. That cycle is real but shallow, so the + // future is boxed instead of letting the async state machine recurse. + let cancellation = Box::pin(self.cancel_dialog_turn(session_id, turn_id)).await; + if let Err(error) = cancellation { + warn!( + "Failed to stop thread goal turn: session_id={}, turn_id={}, error={}", + session_id, turn_id, error + ); + } + } + + /// Drop a dispatch hold an interrupted turn left on the session. + /// + /// An interrupted turn parks every submission that is not a user submission + /// until the user recovers or sends a new turn. A thread goal never submits a + /// user turn, so an explicit goal activation has to retire that fence first; + /// otherwise the goal is active with a kickoff that is parked forever. + async fn release_superseded_interrupted_turn(&self, session_id: &str) { + let holds_dispatch = match self + .session_manager + .latest_dialog_turn_holds_dispatch(session_id) + .await + { + Ok(holds_dispatch) => holds_dispatch, + Err(error) => { + warn!( + "Failed to inspect the interrupted turn fence for a thread goal: session_id={}, error={}", + session_id, error + ); + return; + } + }; + if !holds_dispatch { + return; + } + let interrupted_turn_id = self + .session_manager + .get_session(session_id) + .and_then(|session| session.dialog_turn_ids.last().cloned()); + match self + .session_manager + .abandon_interrupted_dialog_turn(session_id, interrupted_turn_id.as_deref()) + .await + { + Ok(Some(abandoned_turn_id)) => info!( + "Retired interrupted turn for thread goal activation: session_id={}, turn_id={}", + session_id, abandoned_turn_id + ), + Ok(None) => {} + Err(error) => warn!( + "Failed to retire interrupted turn for thread goal activation: session_id={}, error={}", + session_id, error + ), + } + } + + /// Hand goal steering to the runtime without waiting for the delivery to finish. + /// + /// The runtime injects steering into a running turn, but an idle session makes + /// it admit the goal's next turn, which costs seconds on a large workspace. The + /// caller is an interactive command that already persisted the goal state, so it + /// must not block on turn admission; the delivery outcome is reported by log only. + fn schedule_thread_goal_steering( + &self, + session_id: &str, + goal: &ThreadGoal, + kind: AgentThreadGoalDeliveryKind, + ) { if !goal.is_active() { return; } + let kind_label = match kind { + AgentThreadGoalDeliveryKind::Resumed => "resumed", + AgentThreadGoalDeliveryKind::ObjectiveUpdated => "objective_updated", + }; let agent_type = match self.session_manager.get_session(session_id) { Some(session) => { let agent_type = session.agent_type.trim(); @@ -5295,8 +5440,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(runtime) => runtime, Err(error) => { warn!( - "Agent runtime lifecycle delivery is not available; thread goal resume steering skipped: session_id={}, error={}", - session_id, error + "Agent runtime lifecycle delivery is not available; thread goal steering skipped: session_id={}, kind={}, error={}", + session_id, kind_label, error ); return; } @@ -5308,14 +5453,15 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_path, remote_connection_id, remote_ssh_host, - kind: AgentThreadGoalDeliveryKind::Resumed, + kind, goal, }) .await { warn!( - "Failed to deliver thread goal resume steering: session_id={}, error={}", + "Failed to deliver thread goal steering: session_id={}, kind={}, error={}", session_id, + kind_label, CoreServiceAgentRuntime::runtime_error_message(error) ); } @@ -5431,6 +5577,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .get_thread_goal(&session_id, storage_path.as_path()) .await?; let replace_existing = existing.is_some(); + // An explicit goal supersedes a recoverable interruption: the objective + // steering below is not a user turn, so as long as that interrupted turn + // fences dispatch the goal would stay active with a parked kickoff. + self.release_superseded_interrupted_turn(&session_id).await; let goal = self .set_thread_goal_objective( &session_id, diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 8ee1d7329e..013119ddb3 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -5122,6 +5122,52 @@ mod tests { assert_eq!(paused.status, ThreadGoalStatus::Paused); } + #[tokio::test] + async fn editing_a_paused_goal_puts_it_back_to_work() { + let (scheduler, sessions, _, root) = test_scheduler_with_persistence(true); + let session = "goal-edit"; + mark_session_processing(&sessions, &root, session, "goal-turn").await; + let storage = sessions + .effective_session_storage_path(session) + .await + .unwrap(); + scheduler + .coordinator + .create_thread_goal(session, &storage, "finish the work".into(), None) + .await + .unwrap(); + + // Saving the objective the goal already carries is a restart too: the user + // opened the edit dialog to put the goal back to work, so it must not come + // back as active-with-nothing-running. + scheduler + .coordinator + .set_thread_goal_status(session, &storage, ThreadGoalStatus::Paused) + .await + .unwrap(); + let unchanged = scheduler + .coordinator + .update_thread_goal_objective(session, &storage, "finish the work".into()) + .await + .unwrap(); + assert_eq!(unchanged.status, ThreadGoalStatus::Active); + + // A rewritten objective keeps the goal running instead of inheriting the + // pause it was edited from. + scheduler + .coordinator + .set_thread_goal_status(session, &storage, ThreadGoalStatus::Paused) + .await + .unwrap(); + let edited = scheduler + .coordinator + .update_thread_goal_objective(session, &storage, "finish the rest".into()) + .await + .unwrap(); + assert_eq!(edited.status, ThreadGoalStatus::Active); + assert_eq!(edited.objective, "finish the rest"); + } + #[tokio::test] async fn thread_goal_plain_prompt_steering_activates_without_an_extra_turn() { let (scheduler, session_manager, _, root) = test_scheduler_with_persistence(true); diff --git a/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts b/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts index 428db454df..8735666484 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts +++ b/src/web-ui/src/flow_chat/components/ChatInput.appearance.ts @@ -49,7 +49,7 @@ export const chatInputAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'command', attribute: 'data-openbitfun-command', values: ['actions', 'all', 'skills', 'modes'] }, { id: 'commandItemKind', attribute: 'data-openbitfun-command-item-kind', values: ['action', 'mode', 'skill', 'mcp'] }, { id: 'action', attribute: 'data-openbitfun-action', values: ['cancel', 'continue-interrupted', 'retry', 'send', 'split'] }, - { id: 'boostItemKind', attribute: 'data-openbitfun-boost-item-kind', values: ['directive', 'agent', 'context', 'skill', 'additional-mode', 'manage'] }, + { id: 'boostItemKind', attribute: 'data-openbitfun-boost-item-kind', values: ['directive', 'agent', 'context', 'skill', 'additional-mode', 'manage', 'thread-goal'] }, ], states: [ { id: 'processing', selector: { kind: 'ancestorPart', part: 'root', suffix: '[data-openbitfun-state~="processing"]' } }, diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 99e416daee..cbdb408d93 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -11,7 +11,7 @@ import { withConversationExcerpts } from '../utils/composerPresentation'; import React, { useRef, useCallback, useEffect, useReducer, useState, useMemo, useSyncExternalStore } from 'react'; import path from 'path-browserify'; import { useTranslation } from 'react-i18next'; -import { RotateCcw, Loader2, Play } from 'lucide-react'; +import { RotateCcw, Loader2, Play, Goal } from 'lucide-react'; import { ContextDropZone, useContextStore, useContextStoreApi } from '../../shared/context-system'; import { useConversationViewScope } from '../contexts/conversationViewScope'; import { useActiveSessionState } from '@/flow_chat/hooks'; @@ -5642,6 +5642,15 @@ export const ChatInput: React.FC = ({ window.setTimeout(() => richTextInputRef.current?.focus(), 0); }, [dispatchInput, getRichTextTriggerController, inlineTriggerState.isActive, inputState.value, setQueuedInput]); + const handleBoostOpenThreadGoal = useCallback( + (e: React.SyntheticEvent) => { + e.stopPropagation(); + dispatchMode({ type: 'CLOSE_DROPDOWN' }); + void threadGoalController.openGoalEntry(); + }, + [dispatchMode, threadGoalController] + ); + const handleBoostStartBtw = useCallback( (e: React.SyntheticEvent) => { e.stopPropagation(); @@ -6265,6 +6274,18 @@ export const ChatInput: React.FC = ({ executionTarget={effectiveTargetSession?.config.executionTarget} dispatchControl={dispatchControl} worktreeControl={worktreeControl} + threadGoal={canUseThreadGoal && threadGoalController.goal + ? { + goal: threadGoalController.goal, + actions: threadGoalController.availableActions, + onOpen: () => { + void threadGoalController.openGoalEntry(); + }, + onAction: action => { + void threadGoalController.runUiAction(action); + }, + } + : undefined} deferPassiveGitRefresh={deferChatStripPassiveGitRefresh} permissionControl={showPermissionModeControl ? caps.sessionScopedApproval @@ -6963,6 +6984,22 @@ export const ChatInput: React.FC = ({ )} + {canUseThreadGoal && !!effectiveTargetSession?.workspacePath && ( + <> + + } + onClick={handleBoostOpenThreadGoal} + > + {t('chatInput.boostThreadGoal')} + + + )} + {!!currentSessionId && !isBtwSession && ( <> diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts index 5e3fad2c1d..b5dd4f3650 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts @@ -10,11 +10,20 @@ export const chatInputWorkspaceStripAppearanceDescriptor: AppearanceSurfaceDescr { id: 'workspaceOption' }, { id: 'branch' }, { id: 'divider' }, { id: 'permission' }, { id: 'permissionMenu' }, { id: 'permissionOptions' }, { id: 'usageAction' }, + { id: 'goal' }, + // Quick goal controls are separate parts, not states of the readout: + // pausing and clearing are gestures on the goal, not ways it can look. + { id: 'goalRun' }, { id: 'goalClear' }, ], states: [ { id: 'open', selector: { kind: 'self', suffix: '[data-openbitfun-state~="open"]' } }, { id: 'selected', selector: { kind: 'self', suffix: '[data-openbitfun-state~="selected"]' } }, { id: 'active', selector: { kind: 'self', suffix: '[data-openbitfun-state~="active"]' } }, + // The goal control carries its tone as its state, so these three exist as + // states of the track rather than as classes that skins must guess at. + { id: 'paused', selector: { kind: 'self', suffix: '[data-openbitfun-state~="paused"]' } }, + { id: 'blocked', selector: { kind: 'self', suffix: '[data-openbitfun-state~="blocked"]' } }, + { id: 'complete', selector: { kind: 'self', suffix: '[data-openbitfun-state~="complete"]' } }, { id: 'armed', selector: { kind: 'self', suffix: '[data-openbitfun-state~="armed"]' } }, ], }; diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss index c72f374d6b..ec5326aac2 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.scss @@ -275,6 +275,58 @@ $track-item-gap: 10px; width: min(240px, calc(100vw - 16px)); } + // The one control whose subject keeps working while nobody is watching, so + // its flag is the state: running, parked, or stuck. Only the states that wait + // on the user raise their voice — a healthy goal stays part of the hushed line + // — and a raised state keeps its ink through hover, because a blocked goal + // must not soften into the resting hover colour while the user points at it. + &__goal-btn { + @include strip-control; + + // Running is the quiet case, so only the glyph says the goal is alive. + // `` owns the svg, so the flag is tinted on the icon slot itself; + // hovering the control then lifts the label without dimming the flag. + &--active > [data-openbitfun-component='icon'] { + color: var(--openbitfun-color-accent-default); + } + + &--paused, + &--paused:hover:not(:disabled) { + color: var(--openbitfun-color-status-warning-emphasis); + } + + &--blocked, + &--blocked:hover:not(:disabled) { + color: var(--openbitfun-color-status-danger-emphasis); + } + } + + // State and elapsed time are one reading of one goal, so they share the + // control's ink and leave the track together when it runs out of width. + &__goal-text { + display: inline-flex; + min-width: 0; + align-items: center; + gap: $track-part-gap; + } + + // The goal's own two exits. They sit after the readout rather than inside it, + // because acting on a goal is a different gesture from opening it, and they + // are square like the context ring so a track with a goal keeps one rhythm. + &__goal-run, + &__goal-clear { + @include strip-control; + + width: 18px; + padding: 0; + } + + // Clearing cannot be undone, so the one irreversible control on the track says + // so before it is pressed instead of joining the quiet ink of the rest. + &__goal-clear:hover:not(:disabled) > [data-openbitfun-component='icon'] { + color: var(--openbitfun-color-status-danger-emphasis); + } + &__usage-btn { @include strip-control; @@ -517,6 +569,17 @@ $track-item-gap: 10px; &__chip--branch { max-width: 84px; } + + // The words go and the flag stays, so the row keeps knowing a goal is + // driving at the width where it can no longer say what it is doing. + &__goal-text { + display: none; + } + + &__goal-btn { + width: 18px; + padding: 0; + } } } diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx index f9542bad9c..1e9b641e39 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -29,14 +29,34 @@ vi.mock('react-i18next', () => ({ init: vi.fn(), }, useTranslation: () => ({ - t: (key: string, options?: { defaultValue?: string }) => ({ - 'deepReviewConsent.strategyLabels.normal': 'Standard', - 'reasoningSelector.auto': 'Auto', - 'chatInput.permissionMode.ask.label': 'Ask', - 'strip.newWorktree': 'New Worktree', - 'workspaceStrip.primaryAssistant': 'Primary assistant', - 'workspaceStrip.personalAssistant': 'Personal assistant', - } as Record)[key] ?? options?.defaultValue ?? key, + // Mapped keys resolve like the real catalogs do, placeholders included, so + // a case can assert the string the user reads instead of the key. + t: (key: string, options?: Record) => { + const template = ({ + 'deepReviewConsent.strategyLabels.normal': 'Standard', + 'reasoningSelector.auto': 'Auto', + 'chatInput.permissionMode.ask.label': 'Ask', + 'strip.newWorktree': 'New Worktree', + 'threadGoal.stripOpenWithGoal': 'Open thread goal', + 'threadGoal.stripPauseGoal': 'Pause goal', + 'threadGoal.stripResumeGoal': 'Resume goal', + 'threadGoal.stripClearGoal': 'Clear goal', + 'threadGoal.stripTooltipWithGoal': '{{status}}: {{objective}}', + 'threadGoal.status.active': 'Active', + 'threadGoal.status.paused': 'Paused', + 'usage.duration.seconds': '{{value}}s', + 'usage.duration.minutes': '{{value}}m', + 'usage.duration.hoursMinutes': '{{hours}}h {{minutes}}m', + 'workspaceStrip.primaryAssistant': 'Primary assistant', + 'workspaceStrip.personalAssistant': 'Personal assistant', + } as Record)[key] ?? (options?.defaultValue as string | undefined) ?? key; + return template.replace( + /\{\{(\w+)\}\}/g, + (placeholder, name: string) => ( + options?.[name] === undefined ? placeholder : String(options[name]) + ), + ); + }, }), })); @@ -1279,4 +1299,342 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { expect(dispatchTrigger).not.toBeNull(); expect(dispatchTrigger?.dataset.locked).toBe('true'); }); + + // The goal entry appears only while a goal exists, so "no goal" is the + // absence of the control rather than a control that says so. + it('omits the goal control while the session has no goal', async () => { + await act(async () => { + root.render( + + ); + }); + + expect(container.querySelector('[data-openbitfun-part="goal"]')).toBeNull(); + }); + + it('reads the goal as its state and its elapsed time, not as its objective', async () => { + await act(async () => { + root.render( + + ); + }); + + const control = container.querySelector( + '[data-testid="chat-input-thread-goal"]', + ); + expect(control).not.toBeNull(); + expect(control?.dataset.openbitfunPart).toBe('goal'); + expect(control?.dataset.openbitfunState).toBe('active'); + expect(control?.getAttribute('aria-haspopup')).toBe('dialog'); + expect(control?.getAttribute('aria-label')).toBe('Open thread goal'); + expect(control?.textContent).toContain('Active'); + expect(control?.textContent).toContain('1h 30m'); + // The objective is long-form, so it stays in the tooltip rather than + // competing with the state for the width of the track. + expect(control?.textContent).not.toContain('Ship the goal entry'); + expect(control?.dataset.tooltip).toContain('Ship the goal entry'); + }); + + // A goal that has not driven a turn yet has nothing to report, and "0s" + // would read as a stalled goal rather than a new one. + it('omits the elapsed time until the goal has driven a turn', async () => { + await act(async () => { + root.render( + + ); + }); + + const control = container.querySelector('[data-testid="chat-input-thread-goal"]'); + expect(control?.textContent).toContain('Active'); + expect(control?.textContent).not.toContain('0s'); + }); + + // A goal that stopped driving turns must not look like one that is running: + // the state channel is the tone, so the runtime's five non-running statuses + // have to land on the three tones this track can show. + it.each([ + ['paused', 'paused'], + ['blocked', 'blocked'], + ['usageLimited', 'blocked'], + ['budgetLimited', 'blocked'], + ['complete', 'complete'], + ])('carries %s as the %s state', async (status, tone) => { + await act(async () => { + root.render( + + ); + }); + + expect( + container.querySelector('[data-testid="chat-input-thread-goal"]')?.getAttribute( + 'data-openbitfun-state', + ), + ).toBe(tone); + }); + + it('opens the goal from the control', async () => { + const onOpen = vi.fn(); + await act(async () => { + root.render( + + ); + }); + + await act(async () => { + container.querySelector('[data-testid="chat-input-thread-goal"]')?.click(); + }); + + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it('closes the rail to the right of the execution target', async () => { + await act(async () => { + root.render( + + ); + }); + + const children = Array.from( + container.querySelector('[data-openbitfun-part="context"]')?.children ?? [], + ); + const locationIndex = children.indexOf( + container.querySelector('.openbitfun-chat-input-workspace-strip__location') as Element, + ); + const goalIndex = children.indexOf( + container.querySelector('[data-testid="chat-input-thread-goal"]') as Element, + ); + const dispatchIndex = children.indexOf( + container.querySelector('[data-testid="chat-input-dispatch-trigger"]') as Element, + ); + + expect(locationIndex).toBeGreaterThanOrEqual(0); + expect(dispatchIndex).toBeGreaterThan(locationIndex); + expect(goalIndex).toBeGreaterThan(dispatchIndex); + }); + + // The runtime accounts the goal's wall clock at turn accounting points, so the + // value arrives in steps. A goal that is running keeps spending time between + // those snapshots, so the track counts on instead of standing still. + it('counts the goal elapsed time while the goal runs', async () => { + const readout = () => container + .querySelector('[data-testid="chat-input-thread-goal"]') + ?.textContent; + + vi.useFakeTimers(); + try { + act(() => { + root.render( + + ); + }); + + expect(readout()).toContain('5s'); + + act(() => { + vi.advanceTimersByTime(3_000); + }); + + expect(readout()).toContain('8s'); + } finally { + vi.useRealTimers(); + } + }); + + // A goal that stopped running is not spending time, so its readout holds the + // accounted value even though it is the same track. + it('freezes the elapsed time while the goal is not running', async () => { + const readout = () => container + .querySelector('[data-testid="chat-input-thread-goal"]') + ?.textContent; + + vi.useFakeTimers(); + try { + act(() => { + root.render( + + ); + }); + + act(() => { + vi.advanceTimersByTime(3_000); + }); + + expect(readout()).toContain('5s'); + } finally { + vi.useRealTimers(); + } + }); + + // A paused goal is one the user has to release, so the run control becomes the + // action that releases it — the same list the menu is built from. + it('offers resume in place of pause while the goal waits on the user', async () => { + const onAction = vi.fn(); + await act(async () => { + root.render( + + ); + }); + + const run = container.querySelector( + '[data-testid="chat-input-thread-goal-run"]', + ); + expect(run?.getAttribute('aria-label')).toBe('Resume goal'); + + await act(async () => { + run?.click(); + }); + + expect(onAction).toHaveBeenCalledWith('resume'); + }); + + it('runs the goal actions the menu offers from the track', async () => { + const onOpen = vi.fn(); + const onAction = vi.fn(); + await act(async () => { + root.render( + + ); + }); + + const run = container.querySelector( + '[data-testid="chat-input-thread-goal-run"]', + ); + const clear = container.querySelector( + '[data-testid="chat-input-thread-goal-clear"]', + ); + expect(run?.dataset.openbitfunPart).toBe('goalRun'); + expect(run?.getAttribute('aria-label')).toBe('Pause goal'); + expect(clear?.dataset.openbitfunPart).toBe('goalClear'); + expect(clear?.getAttribute('aria-label')).toBe('Clear goal'); + + await act(async () => { + run?.click(); + }); + await act(async () => { + clear?.click(); + }); + + expect(onAction.mock.calls.map(([action]) => action)).toEqual(['pause', 'clear']); + // Acting on the goal is not opening it, so the readout stays untouched. + expect(onOpen).not.toHaveBeenCalled(); + }); + + // A finished or budget-limited goal has nothing left to stop: only the exit. + it('offers no run control for a goal that no longer runs', async () => { + await act(async () => { + root.render( + + ); + }); + + expect(container.querySelector('[data-testid="chat-input-thread-goal-run"]')).toBeNull(); + expect(container.querySelector('[data-testid="chat-input-thread-goal-clear"]')).not.toBeNull(); + }); + + // A surface that cannot run goal actions gets the fact, not a control that + // would do nothing when pressed. + it('keeps the quick controls away where the surface cannot run them', async () => { + await act(async () => { + root.render( + + ); + }); + + expect(container.querySelector('[data-testid="chat-input-thread-goal-run"]')).toBeNull(); + expect(container.querySelector('[data-testid="chat-input-thread-goal-clear"]')).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index c7d1b13cfc..8b7f764237 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -2,10 +2,10 @@ import { useDeviceDirectory, resolveDeviceName } from '@/infrastructure/account/ /** * Two fixed rails in the composer's upper context band. * - * The left rail is the situation the session is in — its workspace and branch, - * followed by the local/remote execution target. Worktree isolation is a local - * target mode. The right rail is the contract for the next turn — how much - * confirmation it asks for and how + * The left rail is the situation the session is in — where it runs, on which + * branch, on which execution target, and what long-horizon goal it is chasing. + * Worktree isolation is a local target mode. The right rail is the contract for + * the next turn — how much confirmation it asks for and how * much context is left. Nothing is centered and no column template is * conditional, so a control appearing or disappearing cannot move the rest of * the track. @@ -30,6 +30,12 @@ import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPos import { DispatchResultDialog } from '@/features/dispatch/DispatchResultDialog'; import { DispatchTargetPicker } from '@/features/dispatch/DispatchTargetPicker'; import type { DispatchSelection, DispatchTarget } from '@/features/dispatch/types'; +import type { ThreadGoalSnapshot } from '../services/goalService'; +import type { ThreadGoalUiAction } from '../services/threadGoalActions'; +import { + ThreadGoalStripControl, + type ThreadGoalStripAction, +} from './thread-goal/ThreadGoalStripControl'; import { formatCompactTokenCount } from '../utils/tokenUsageDisplay'; import './ChatInputWorkspaceStrip.scss'; @@ -107,6 +113,18 @@ export interface ChatInputWorkspaceStripProps { lockedReason?: 'dispatch'; onChange: (enabled: boolean) => void; }; + /** + * Thread goal entry (/goal) — what the session is chasing, on the left rail. + * Omitted while the session has no goal: an unset goal is not a state of the + * track, and the composer offers "set a goal" through its boost menu. + */ + threadGoal?: { + goal: ThreadGoalSnapshot; + /** Actions the goal menu offers for the current status; the track mirrors them. */ + actions?: ThreadGoalUiAction[]; + onOpen: () => void; + onAction?: (action: ThreadGoalStripAction) => void; + }; /** Immutable per-session dispatch destination. Hidden on embedded/mini composers. */ dispatchControl?: { target: DispatchTarget; @@ -152,6 +170,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( deferPassiveGitRefresh = false, executionTarget, worktreeControl, + threadGoal, dispatchControl, }) => { useDeviceDirectory(); @@ -213,6 +232,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( }, [refreshBasic, trimmedPath]); const showUsage = usageReport?.visible && !!usageReport.onOpen; + const showGoal = !!threadGoal?.goal; const showPermission = !!permissionControl; const showDispatchResult = !!dispatchControl?.syncableJobId; const isWorktree = !!executionTarget?.worktreeId; @@ -231,6 +251,9 @@ export const ChatInputWorkspaceStrip: React.FC = ( const isGitWorkspace = isRepository || repositoryTrustRequired || isWorktree || worktreeEnabled; const showWorktreeToggle = !!worktreeControl && isGitWorkspace; const showDispatchPicker = !!dispatchControl; + // The goal closes the context rail, so its divider depends on whether any + // other segment — path, execution target, or isolation — got there first. + const showGoalDivider = !!label || showDispatchPicker || showWorktreeToggle; const dispatchPickerLocked = !!dispatchControl && (dispatchControl.locked || !isGitWorkspace); const permissionModeLabels = { ask: t('chatInput.permissionMode.ask.label'), @@ -390,7 +413,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( [currentBranch, dispatchBranch, isRepository, repositoryTrustRequired, t], ); - const hasContextRail = !!label || showDispatchPicker; + const hasContextRail = !!label || showDispatchPicker || showGoal; const hasNextRail = showPermission || showUsage || showDispatchResult; const branchLabel = dispatchBranch || (branchSwitchable ? currentBranch?.trim() : undefined) @@ -819,6 +842,20 @@ export const ChatInputWorkspaceStrip: React.FC = ( ? renderDivider('context-isolation') : null} {!showDispatchPicker ? renderWorktreeToggle() : null} + {showGoal && threadGoal ? ( + <> + {/* The goal reads with the execution target, not with the path: it + is what the session is doing where it runs. It closes the rail, + so it only needs a divider when something precedes it. */} + {showGoalDivider ? renderDivider('context-goal') : null} + + + ) : null}
, + elapsedSeconds: number, t: ReturnType['t'] ): string | null { const parts: string[] = []; @@ -44,10 +47,10 @@ function formatUsageLine( }) ); } - if ((goal.timeUsedSeconds ?? 0) > 0) { + if (elapsedSeconds > 0) { parts.push( t('threadGoal.usageTime', { - seconds: goal.timeUsedSeconds, + duration: formatThreadGoalElapsedSeconds(elapsedSeconds, t), }) ); } @@ -88,7 +91,17 @@ export const ThreadGoalDialogs: React.FC = ({ const statusLabel = goal ? resolveThreadGoalStatusLabel(t, goal.status) : ''; - const usageLine = goal ? formatUsageLine(goal, t) : null; + // `pause` is what the menu offers exactly while the goal is driving turns, so + // the readiness to pause is the policy's own answer to "is this goal running" + // — the same rule the track reads its stopwatch from. The clock only runs while + // the menu is in front of the user, since nothing else shows the readout. + const elapsedSeconds = useThreadGoalElapsedSeconds({ + goalId: goal?.goalId, + accountedSeconds: goal?.timeUsedSeconds ?? 0, + advancing: controller.menuOpen && controller.availableActions.includes('pause'), + }); + + const usageLine = goal ? formatUsageLine(goal, elapsedSeconds, t) : null; const workflowSteps = useMemo( () => (goal ? buildThreadGoalWorkflowSteps(goal.status) : []), diff --git a/src/web-ui/src/flow_chat/components/thread-goal/ThreadGoalStripControl.tsx b/src/web-ui/src/flow_chat/components/thread-goal/ThreadGoalStripControl.tsx new file mode 100644 index 0000000000..43b70a55f9 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/thread-goal/ThreadGoalStripControl.tsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Goal, Pause, Play, Trash2 } from 'lucide-react'; +import { Icon, Tooltip } from '@openbitfun/ui'; +import type { ThreadGoalSnapshot } from '../../services/goalService'; +import type { ThreadGoalUiAction } from '../../services/threadGoalActions'; +import { resolveThreadGoalStatusLabel } from '../../utils/threadGoalDisplay'; +import { formatUsageDuration } from '../usage/usageReportUtils'; +import { resolveThreadGoalStripTone } from './threadGoalStripTone'; +import { useThreadGoalElapsedSeconds } from './useThreadGoalElapsedSeconds'; + +/** The goal actions the track carries as controls of its own; the rest stay in the menu. */ +export type ThreadGoalStripAction = Extract; + +export interface ThreadGoalStripControlProps { + goal: ThreadGoalSnapshot; + /** + * Actions the goal menu offers for the current status. The quick controls + * mirror this list rather than judging the status themselves, so the track and + * the menu cannot disagree about what this goal can do. + */ + actions?: ThreadGoalUiAction[]; + onOpen: () => void; + /** Omitted where the surface cannot run goal actions; those controls then stay away. */ + onAction?: (action: ThreadGoalStripAction) => void; +} + +/** + * The thread-goal segment of the composer's status track. + * + * It sits to the right of the execution target and reads as one fact: what the + * session is chasing, what that goal is doing right now, and how long it has + * been at it. The objective is long-form, so it stays in the tooltip — the + * track has room for the state, not for the prose. + * + * Its state is the tone it carries, because a goal that stopped driving turns + * while nobody was watching must not look like one that is running. + * + * The two controls beside it are the exits a user reaches for while watching the + * session work — stop the goal, or drop it. Editing the objective is a dialog, + * so it stays in the menu. + */ +export const ThreadGoalStripControl: React.FC = ({ + goal, + actions = [], + onOpen, + onAction, +}) => { + const { t } = useTranslation('flow-chat'); + const tone = resolveThreadGoalStripTone(goal); + const statusLabel = resolveThreadGoalStatusLabel(t, goal.status); + // `pause` is what the menu offers exactly while the goal is driving turns, so + // the readiness to pause is the policy's own answer to "is this goal running". + const running = actions.includes('pause'); + const elapsedSeconds = useThreadGoalElapsedSeconds({ + goalId: goal.goalId, + accountedSeconds: goal.timeUsedSeconds ?? 0, + // A running goal is counted continuously, not only while one round happens to + // be in flight, so the readout ticks while the goal runs. + advancing: running, + }); + + const runAction: ThreadGoalStripAction | null = running + ? 'pause' + : actions.includes('resume') + ? 'resume' + : null; + const quickRun = onAction && runAction + ? { + action: runAction, + label: runAction === 'resume' + ? t('threadGoal.stripResumeGoal') + : t('threadGoal.stripPauseGoal'), + glyph: runAction === 'resume' ? Play : Pause, + } + : null; + const clearLabel = t('threadGoal.stripClearGoal'); + const showClear = Boolean(onAction) && actions.includes('clear'); + + return ( + <> + + + + {quickRun ? ( + + + + ) : null} + {showClear ? ( + + + + ) : null} + + ); +}; + +ThreadGoalStripControl.displayName = 'ThreadGoalStripControl'; diff --git a/src/web-ui/src/flow_chat/components/thread-goal/threadGoalStripTone.test.ts b/src/web-ui/src/flow_chat/components/thread-goal/threadGoalStripTone.test.ts new file mode 100644 index 0000000000..0078e82da3 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/thread-goal/threadGoalStripTone.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveThreadGoalStripTone } from './threadGoalStripTone'; + +describe('resolveThreadGoalStripTone', () => { + it('keeps a running goal on the accent tone', () => { + expect(resolveThreadGoalStripTone({ status: 'active' })).toBe('active'); + }); + + it('separates a parked goal from one the runtime blocked', () => { + expect(resolveThreadGoalStripTone({ status: 'paused' })).toBe('paused'); + expect(resolveThreadGoalStripTone({ status: 'blocked' })).toBe('blocked'); + }); + + it('treats exhausted quota and exhausted budget as the same stuck state', () => { + expect(resolveThreadGoalStripTone({ status: 'usageLimited' })).toBe('blocked'); + expect(resolveThreadGoalStripTone({ status: 'budgetLimited' })).toBe('blocked'); + // A runtime that still reports the snake_case spelling names the same state. + expect(resolveThreadGoalStripTone({ status: 'usage_limited' })).toBe('blocked'); + expect(resolveThreadGoalStripTone({ status: 'budget_limited' })).toBe('blocked'); + }); + + it('quiets a finished goal', () => { + expect(resolveThreadGoalStripTone({ status: 'complete' })).toBe('complete'); + }); + + it('keeps a goal this build cannot name visible instead of hiding it', () => { + expect(resolveThreadGoalStripTone({ status: '' })).toBe('active'); + expect(resolveThreadGoalStripTone({ status: 'queued' })).toBe('active'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/thread-goal/threadGoalStripTone.ts b/src/web-ui/src/flow_chat/components/thread-goal/threadGoalStripTone.ts new file mode 100644 index 0000000000..ce4dc0c2a6 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/thread-goal/threadGoalStripTone.ts @@ -0,0 +1,46 @@ +import type { ThreadGoalSnapshot } from '../../services/goalService'; + +/** + * A thread goal drives turns on its own, so a goal that has stopped driving + * them must not look like one that is running. Running, parked, and stuck are + * three different situations, and colour is the track's only state channel — + * the goal control carries no outline. `complete` is the quiet one: it is the + * only state that is not waiting for the user. + */ +export type ThreadGoalStripTone = 'active' | 'paused' | 'blocked' | 'complete'; + +function normalizeThreadGoalStatus(status: string | undefined): string { + const raw = status?.trim() ?? ''; + if (!raw) { + return ''; + } + const camel = raw.charAt(0).toLowerCase() + raw.slice(1); + if (camel === 'usage_limited') { + return 'usageLimited'; + } + if (camel === 'budget_limited') { + return 'budgetLimited'; + } + return camel; +} + +export function resolveThreadGoalStripTone( + goal: Pick, +): ThreadGoalStripTone { + switch (normalizeThreadGoalStatus(goal.status)) { + case 'paused': + return 'paused'; + // A goal out of budget or out of quota is stuck for the same reason as one + // the runtime blocked: it will not advance until the user acts. + case 'blocked': + case 'usageLimited': + case 'budgetLimited': + return 'blocked'; + case 'complete': + return 'complete'; + // `active` and anything this build cannot name: a goal that exists is not + // the absence of one, and the tooltip still carries its literal status. + default: + return 'active'; + } +} diff --git a/src/web-ui/src/flow_chat/components/thread-goal/useThreadGoalElapsedSeconds.ts b/src/web-ui/src/flow_chat/components/thread-goal/useThreadGoalElapsedSeconds.ts new file mode 100644 index 0000000000..233801be1f --- /dev/null +++ b/src/web-ui/src/flow_chat/components/thread-goal/useThreadGoalElapsedSeconds.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from 'react'; + +const TICK_INTERVAL_MS = 1000; + +export interface ThreadGoalElapsedClock { + /** Identity of the goal the accounted value belongs to. */ + goalId?: string; + /** Seconds the backend has accounted for this goal so far. */ + accountedSeconds: number; + /** True while a turn is driving the goal, which is the only time it is counted. */ + advancing: boolean; +} + +/** + * Elapsed seconds for one goal, counted up while the goal is being driven. + * + * The runtime accounts `timeUsedSeconds` at turn accounting points, so the value + * arrives in steps and a track that only echoed it would jump. While a turn is + * driving the goal the elapsed time really is growing, so the readout counts on + * from the last accounted value and reads as a stopwatch instead. + */ +export function useThreadGoalElapsedSeconds({ + goalId, + accountedSeconds, + advancing, +}: ThreadGoalElapsedClock): number { + const [displayedSeconds, setDisplayedSeconds] = useState(accountedSeconds); + const goalIdRef = useRef(goalId); + + useEffect(() => { + // Another goal is another clock. For the same goal the readout only moves + // forward: an accounting snapshot can land behind the seconds already + // counted here, and a goal must not appear to lose time. + if (goalIdRef.current !== goalId) { + goalIdRef.current = goalId; + setDisplayedSeconds(accountedSeconds); + return; + } + setDisplayedSeconds(previous => Math.max(previous, accountedSeconds)); + }, [accountedSeconds, goalId]); + + useEffect(() => { + if (!advancing) return undefined; + // Count from the clock rather than once per tick, so a timer the browser + // throttled in a background tab does not silently lose the seconds it slept + // through. + let lastTickAt = Date.now(); + const timer = globalThis.setInterval(() => { + const now = Date.now(); + const advancedSeconds = Math.floor((now - lastTickAt) / 1000); + if (advancedSeconds <= 0) return; + lastTickAt += advancedSeconds * 1000; + setDisplayedSeconds(previous => previous + advancedSeconds); + }, TICK_INTERVAL_MS); + return () => globalThis.clearInterval(timer); + }, [advancing]); + + return displayedSeconds; +} diff --git a/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts b/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts index 21a8848e39..9df10da8ba 100644 --- a/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts +++ b/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts @@ -83,9 +83,17 @@ export function formatUsageDuration(value: number | undefined, t: Translator): s const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; - return remainingMinutes === 0 - ? t('usage.duration.hours', { value: hours }) - : t('usage.duration.hoursMinutes', { hours, minutes: remainingMinutes }); + if (hours < 24) { + return remainingMinutes === 0 + ? t('usage.duration.hours', { value: hours }) + : t('usage.duration.hoursMinutes', { hours, minutes: remainingMinutes }); + } + + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return remainingHours === 0 + ? t('usage.duration.days', { value: days }) + : t('usage.duration.daysHours', { days, hours: remainingHours }); } export function formatUsageTimestamp(value: number | undefined, t: Translator): string { diff --git a/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts b/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts index c051826c02..6dbdf4807d 100644 --- a/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts +++ b/src/web-ui/src/flow_chat/hooks/useThreadGoalController.ts @@ -121,6 +121,12 @@ export function useThreadGoalController( const [editInitialObjective, setEditInitialObjective] = useState(''); const [resumeOpen, setResumeOpen] = useState(false); const lastResumePromptKey = useRef(null); + /** + * Goal the user paused on purpose. A pause the user asked for is not an + * interruption to report back, so it must not raise the resume prompt; a pause + * that happened for another reason (a stopped turn, a usage limit) still does. + */ + const userPausedGoalId = useRef(null); const goal = storeGoal; @@ -190,6 +196,11 @@ export function useThreadGoalController( return; } lastResumePromptKey.current = resumePromptKey(goal); + // The user paused this goal from the track or the menu, so the paused state + // they are looking at is the one they asked for. + if (userPausedGoalId.current && goal?.goalId === userPausedGoalId.current) { + return; + } setResumeOpen(true); }, [disabled, goal, sceneActive, sessionId]); @@ -234,6 +245,9 @@ export function useThreadGoalController( if (!session || disabled) return null; const parsed = parseGoalCommand(message); if (!parsed) return null; + // `/goal pause` is the same deliberate pause as the track's control, while + // any other goal command leaves the goal free to report an interruption. + userPausedGoalId.current = parsed.kind === 'pause' ? (goal?.goalId ?? null) : null; return runGoalCommandSafely({ session, @@ -254,12 +268,22 @@ export function useThreadGoalController( }, }); }, - [confirmReplaceGoal, disabled, openEdit, session, titles] + [confirmReplaceGoal, disabled, goal?.goalId, openEdit, session, titles] ); const runUiAction = useCallback( async (action: 'clear' | 'pause' | 'resume') => { if (!session || disabled) return; + // Clearing drops the objective together with the time and tokens spent on + // it, and none of that can be recovered, so every entry point asks first. + if (action === 'clear') { + const confirmed = await confirmWarning( + t('threadGoal.clearConfirmTitle'), + t('threadGoal.clearConfirmMessage', { objective: goal?.objective ?? '' }) + ); + if (!confirmed) return; + } + userPausedGoalId.current = action === 'pause' ? (goal?.goalId ?? null) : null; try { await runThreadGoalUiAction(session, action, titles); if (action === 'clear') { @@ -273,7 +297,7 @@ export function useThreadGoalController( notificationService.error(message, { title: titles.failedTitle, duration: 5000 }); } }, - [disabled, session, titles] + [disabled, goal?.goalId, goal?.objective, session, t, titles] ); const saveEdit = useCallback( @@ -286,6 +310,9 @@ export function useThreadGoalController( if (!saved) { return; } + // Saving an objective puts the goal back to work, so the pause the user + // asked for before the edit no longer describes the goal in front of them. + userPausedGoalId.current = null; setEditOpen(false); setMenuOpen(false); } catch (error) { diff --git a/src/web-ui/src/flow_chat/utils/threadGoalDuration.test.ts b/src/web-ui/src/flow_chat/utils/threadGoalDuration.test.ts new file mode 100644 index 0000000000..cce68adc56 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/threadGoalDuration.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import type { TFunction } from 'i18next'; + +import { formatThreadGoalElapsedSeconds } from './threadGoalDuration'; + +// Mirrors the shape of the `threadGoal.duration.*` locale entries: the assertion +// is which units a duration is decomposed into, not the wording of one locale. +const PATTERNS: Record = { + 'threadGoal.duration.seconds': '{{seconds}}s', + 'threadGoal.duration.minutesSeconds': '{{minutes}}m {{seconds}}s', + 'threadGoal.duration.hoursMinutesSeconds': '{{hours}}h {{minutes}}m {{seconds}}s', + 'threadGoal.duration.daysHoursMinutesSeconds': '{{days}}d {{hours}}h {{minutes}}m {{seconds}}s', +}; + +const t = ((key: string, options?: Record) => { + const pattern = PATTERNS[key]; + if (pattern === undefined) { + throw new Error(`Unexpected duration key: ${key}`); + } + return pattern.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => + String(options?.[name] ?? '') + ); +}) as unknown as TFunction; + +describe('formatThreadGoalElapsedSeconds', () => { + it('counts seconds while the goal is younger than a minute', () => { + expect(formatThreadGoalElapsedSeconds(0, t)).toBe('0s'); + expect(formatThreadGoalElapsedSeconds(59, t)).toBe('59s'); + }); + + it('names minutes and the seconds under them', () => { + expect(formatThreadGoalElapsedSeconds(60, t)).toBe('1m 0s'); + expect(formatThreadGoalElapsedSeconds(91, t)).toBe('1m 31s'); + }); + + it('names hours instead of counting a growing minute total', () => { + expect(formatThreadGoalElapsedSeconds(3600, t)).toBe('1h 0m 0s'); + expect(formatThreadGoalElapsedSeconds(3661, t)).toBe('1h 1m 1s'); + expect(formatThreadGoalElapsedSeconds(86_399, t)).toBe('23h 59m 59s'); + }); + + it('names days once a goal has been running that long', () => { + expect(formatThreadGoalElapsedSeconds(86_400, t)).toBe('1d 0h 0m 0s'); + expect(formatThreadGoalElapsedSeconds(90_061, t)).toBe('1d 1h 1m 1s'); + }); + + it('keeps a readout for values a clock cannot supply', () => { + expect(formatThreadGoalElapsedSeconds(-5, t)).toBe('0s'); + expect(formatThreadGoalElapsedSeconds(Number.NaN, t)).toBe('0s'); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/threadGoalDuration.ts b/src/web-ui/src/flow_chat/utils/threadGoalDuration.ts new file mode 100644 index 0000000000..2f0bd3a944 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/threadGoalDuration.ts @@ -0,0 +1,41 @@ +import type { TFunction } from 'i18next'; + +const SECONDS_PER_MINUTE = 60; +const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * 60; +const SECONDS_PER_DAY = SECONDS_PER_HOUR * 24; + +/** + * Elapsed time of a thread goal, spelled out in days, hours, minutes and seconds. + * + * A goal runs for as long as it takes, so a readout that collapses the whole + * duration into a single unit stops being readable: "5400 seconds" says less than + * "1 hour 30 minutes 0 seconds". Every unit the goal has reached is named, and the + * seconds stay exact for the case of a goal that is only minutes old. + */ +export function formatThreadGoalElapsedSeconds(seconds: number, t: TFunction): string { + const total = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0; + const days = Math.floor(total / SECONDS_PER_DAY); + const hours = Math.floor(total / SECONDS_PER_HOUR) % 24; + const minutes = Math.floor(total / SECONDS_PER_MINUTE) % 60; + const remainingSeconds = total % SECONDS_PER_MINUTE; + + if (days > 0) { + return t('threadGoal.duration.daysHoursMinutesSeconds', { + days, + hours, + minutes, + seconds: remainingSeconds, + }); + } + if (hours > 0) { + return t('threadGoal.duration.hoursMinutesSeconds', { + hours, + minutes, + seconds: remainingSeconds, + }); + } + if (minutes > 0) { + return t('threadGoal.duration.minutesSeconds', { minutes, seconds: remainingSeconds }); + } + return t('threadGoal.duration.seconds', { seconds: remainingSeconds }); +} diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 29326e7157..fb5017e7e4 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -188,7 +188,9 @@ "minutes": "{{value}}m", "minutesSeconds": "{{minutes}}m {{seconds}}s", "hours": "{{value}}h", - "hoursMinutes": "{{hours}}h {{minutes}}m" + "hoursMinutes": "{{hours}}h {{minutes}}m", + "days": "{{value}}d", + "daysHours": "{{days}}d {{hours}}h" }, "actions": { "copyMarkdown": "Copy Markdown", @@ -415,6 +417,8 @@ "threadGoal": { "replaceConfirmTitle": "Replace thread goal?", "replaceConfirmMessage": "Replace the current goal?\n\nCurrent: {{existing}}\n\nNew: {{next}}", + "clearConfirmTitle": "Clear thread goal?", + "clearConfirmMessage": "Clear this goal?\n\nGoal: {{objective}}", "menuTitle": "Thread goal", "objectiveLabel": "Objective", "updatedUserMessage": "Thread goal updated: {{objective}}", @@ -454,8 +458,17 @@ "stripTooltipWithGoal": "{{status}}: {{objective}}", "stripOpenEmpty": "Open thread goal", "stripOpenWithGoal": "Open thread goal menu", + "stripPauseGoal": "Pause goal", + "stripResumeGoal": "Resume goal", + "stripClearGoal": "Clear goal", "usageTokens": "{{used}} / {{budget}} tokens", - "usageTime": "{{seconds}}s elapsed", + "usageTime": "{{duration}} elapsed", + "duration": { + "seconds": "{{seconds}}s", + "minutesSeconds": "{{minutes}}m {{seconds}}s", + "hoursMinutesSeconds": "{{hours}}h {{minutes}}m {{seconds}}s", + "daysHoursMinutesSeconds": "{{days}}d {{hours}}h {{minutes}}m {{seconds}}s" + }, "status": { "active": "Active", "paused": "Paused", @@ -936,6 +949,7 @@ "openDefaultSettings": "Open permission settings" }, "boostSectionContext": "Context", + "boostThreadGoal": "Set thread goal", "boostStartBtw": "Start side question", "boostNewSession": "New session", "boostAddContext": "Reference file or folder", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 41822a936f..a383a125fd 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -188,7 +188,9 @@ "minutes": "{{value}}分钟", "minutesSeconds": "{{minutes}}分{{seconds}}秒", "hours": "{{value}}小时", - "hoursMinutes": "{{hours}}小时{{minutes}}分钟" + "hoursMinutes": "{{hours}}小时{{minutes}}分钟", + "days": "{{value}}天", + "daysHours": "{{days}}天{{hours}}小时" }, "actions": { "copyMarkdown": "复制 Markdown", @@ -415,6 +417,8 @@ "threadGoal": { "replaceConfirmTitle": "替换线程目标?", "replaceConfirmMessage": "是否替换当前目标?\n\n当前:{{existing}}\n\n新目标:{{next}}", + "clearConfirmTitle": "清除线程目标?", + "clearConfirmMessage": "是否清除当前目标?\n\n目标:{{objective}}", "menuTitle": "线程目标", "objectiveLabel": "目标描述", "updatedUserMessage": "线程目标已更新:{{objective}}", @@ -454,8 +458,17 @@ "stripTooltipWithGoal": "{{status}}:{{objective}}", "stripOpenEmpty": "打开线程目标", "stripOpenWithGoal": "打开线程目标菜单", + "stripPauseGoal": "暂停目标", + "stripResumeGoal": "恢复目标", + "stripClearGoal": "清除目标", "usageTokens": "Token {{used}} / {{budget}}", - "usageTime": "已用 {{seconds}} 秒", + "usageTime": "已用 {{duration}}", + "duration": { + "seconds": "{{seconds}} 秒", + "minutesSeconds": "{{minutes}} 分 {{seconds}} 秒", + "hoursMinutesSeconds": "{{hours}} 小时 {{minutes}} 分 {{seconds}} 秒", + "daysHoursMinutesSeconds": "{{days}} 天 {{hours}} 小时 {{minutes}} 分 {{seconds}} 秒" + }, "status": { "active": "进行中", "paused": "已暂停", @@ -936,6 +949,7 @@ "openDefaultSettings": "打开权限设置" }, "boostSectionContext": "上下文", + "boostThreadGoal": "设置线程目标", "boostStartBtw": "发起侧问", "boostNewSession": "新建会话", "boostAddContext": "引用文件或文件夹", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 7c79479170..66ceb7d469 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -188,7 +188,9 @@ "minutes": "{{value}}分鐘", "minutesSeconds": "{{minutes}}分{{seconds}}秒", "hours": "{{value}}小時", - "hoursMinutes": "{{hours}}小時{{minutes}}分鐘" + "hoursMinutes": "{{hours}}小時{{minutes}}分鐘", + "days": "{{value}}天", + "daysHours": "{{days}}天{{hours}}小時" }, "actions": { "copyMarkdown": "複製 Markdown", @@ -415,6 +417,8 @@ "threadGoal": { "replaceConfirmTitle": "替換執行緒目標?", "replaceConfirmMessage": "是否替換當前目標?\n\n當前:{{existing}}\n\n新目標:{{next}}", + "clearConfirmTitle": "清除執行緒目標?", + "clearConfirmMessage": "是否清除當前目標?\n\n目標:{{objective}}", "menuTitle": "執行緒目標", "objectiveLabel": "目標描述", "updatedUserMessage": "執行緒目標已更新:{{objective}}", @@ -454,8 +458,17 @@ "stripTooltipWithGoal": "{{status}}:{{objective}}", "stripOpenEmpty": "開啟執行緒目標", "stripOpenWithGoal": "開啟執行緒目標選單", + "stripPauseGoal": "暫停目標", + "stripResumeGoal": "恢復目標", + "stripClearGoal": "清除目標", "usageTokens": "Token {{used}} / {{budget}}", - "usageTime": "已耗時 {{seconds}} 秒", + "usageTime": "已耗時 {{duration}}", + "duration": { + "seconds": "{{seconds}} 秒", + "minutesSeconds": "{{minutes}} 分鐘 {{seconds}} 秒", + "hoursMinutesSeconds": "{{hours}} 小時 {{minutes}} 分鐘 {{seconds}} 秒", + "daysHoursMinutesSeconds": "{{days}} 天 {{hours}} 小時 {{minutes}} 分鐘 {{seconds}} 秒" + }, "status": { "active": "進行中", "paused": "已暫停", @@ -936,6 +949,7 @@ "openDefaultSettings": "開啟權限設定" }, "boostSectionContext": "上下文", + "boostThreadGoal": "設定執行緒目標", "boostStartBtw": "發起側問", "boostNewSession": "新建會話", "boostAddContext": "引用檔案或資料夾",