From 80a061f850248f109e5b10f87984639cbbeae9f8 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 29 Sep 2026 18:41:14 +0800 Subject: [PATCH 1/2] fix(thread-goal): restart edited goals without waiting for turn admission Editing a paused goal restarts it, so the edit path delivered objective-updated steering and awaited it. On an idle session that delivery admits the goal's next turn, which measured 2482-3844 ms and made the edit dialog look frozen. Hand the steering over in the background, the way resume already does, through one shared delivery helper for both kinds. The goal state, its event, and the release of the interruption that parked the kickoff stay on the command path, so an edited goal still comes back as active. --- .../src/agentic/coordination/coordinator.rs | 184 ++++++++++++++++-- .../src/agentic/coordination/scheduler.rs | 46 +++++ 2 files changed, 213 insertions(+), 17 deletions(-) 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); From bf9f26bdfb58e75988088faaf8cdcaf49339905a Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Tue, 29 Sep 2026 18:41:24 +0800 Subject: [PATCH 2/2] feat(flowchat): show the thread goal beside the workspace strip Give the running goal a control next to the local workspace chip: goal icon, status, and a live elapsed timer, plus pause and clear quick actions. Pause acts immediately instead of opening a confirmation, while clear keeps its own dialog. Render goal duration in days, hours, minutes and seconds instead of raw seconds, and bring an edited goal back to running so the pause it was edited from no longer describes it. --- .../components/ChatInput.appearance.ts | 2 +- .../src/flow_chat/components/ChatInput.tsx | 39 +- .../ChatInputWorkspaceStrip.appearance.ts | 9 + .../components/ChatInputWorkspaceStrip.scss | 63 +++ .../ChatInputWorkspaceStrip.test.tsx | 374 +++++++++++++++++- .../components/ChatInputWorkspaceStrip.tsx | 47 ++- .../thread-goal/ThreadGoalDialogs.tsx | 19 +- .../thread-goal/ThreadGoalStripControl.tsx | 153 +++++++ .../thread-goal/threadGoalStripTone.test.ts | 31 ++ .../thread-goal/threadGoalStripTone.ts | 46 +++ .../useThreadGoalElapsedSeconds.ts | 59 +++ .../components/usage/usageReportUtils.ts | 14 +- .../hooks/useThreadGoalController.ts | 31 +- .../utils/threadGoalDuration.test.ts | 51 +++ .../src/flow_chat/utils/threadGoalDuration.ts | 41 ++ src/web-ui/src/locales/en-US/flow-chat.json | 18 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 18 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 18 +- 18 files changed, 1004 insertions(+), 29 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/thread-goal/ThreadGoalStripControl.tsx create mode 100644 src/web-ui/src/flow_chat/components/thread-goal/threadGoalStripTone.test.ts create mode 100644 src/web-ui/src/flow_chat/components/thread-goal/threadGoalStripTone.ts create mode 100644 src/web-ui/src/flow_chat/components/thread-goal/useThreadGoalElapsedSeconds.ts create mode 100644 src/web-ui/src/flow_chat/utils/threadGoalDuration.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/threadGoalDuration.ts 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": "引用檔案或資料夾",