mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-02 12:13:05 +00:00
feat(desktop): keep active sessions aligned with cwd
This commit is contained in:
parent
68680db10d
commit
62af32efe7
10 changed files with 622 additions and 309 deletions
|
|
@ -4,9 +4,10 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||||
import { FadeText } from '@/components/ui/fade-text'
|
import { FadeText } from '@/components/ui/fade-text'
|
||||||
|
import { Codicon } from '@/components/ui/codicon'
|
||||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||||
import { type Translations, useI18n } from '@/i18n'
|
import { type Translations, useI18n } from '@/i18n'
|
||||||
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
|
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
|
|
@ -209,7 +210,7 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
||||||
if (tree.length === 0) {
|
if (tree.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="grid place-items-center gap-3 py-12 text-center">
|
<div className="grid place-items-center gap-3 py-12 text-center">
|
||||||
<Sparkles className="size-6 text-muted-foreground/60" />
|
<Codicon className="text-muted-foreground/60" name="hubot" size="1.5rem" />
|
||||||
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
|
<p className="text-sm font-medium text-foreground/90">{t.agents.emptyTitle}</p>
|
||||||
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
|
<p className="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,10 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||||
onEdit: (message: AppendMessage) => Promise<void>
|
onEdit: (message: AppendMessage) => Promise<void>
|
||||||
onReload: (parentId: string | null) => Promise<void>
|
onReload: (parentId: string | null) => Promise<void>
|
||||||
onRestoreToMessage?: (messageId: string) => Promise<void>
|
onRestoreToMessage?: (
|
||||||
|
messageId: string,
|
||||||
|
target?: { text?: string; userOrdinal?: number | null }
|
||||||
|
) => Promise<void>
|
||||||
onRetryResume: (sessionId: string) => void
|
onRetryResume: (sessionId: string) => void
|
||||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||||
onDismissError?: (messageId: string) => void
|
onDismissError?: (messageId: string) => void
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
FILE_BROWSER_MIN_WIDTH,
|
FILE_BROWSER_MIN_WIDTH,
|
||||||
pinSession,
|
pinSession,
|
||||||
PREVIEW_PANE_ID,
|
PREVIEW_PANE_ID,
|
||||||
|
restoreWorktree,
|
||||||
setSidebarOverlayMounted,
|
setSidebarOverlayMounted,
|
||||||
SIDEBAR_DEFAULT_WIDTH,
|
SIDEBAR_DEFAULT_WIDTH,
|
||||||
SIDEBAR_MAX_WIDTH,
|
SIDEBAR_MAX_WIDTH,
|
||||||
|
|
@ -52,6 +53,8 @@ import {
|
||||||
normalizeProfileKey,
|
normalizeProfileKey,
|
||||||
refreshActiveProfile
|
refreshActiveProfile
|
||||||
} from '../store/profile'
|
} from '../store/profile'
|
||||||
|
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects'
|
||||||
|
import { $reviewOpen, REVIEW_PANE_ID } from '../store/review'
|
||||||
import {
|
import {
|
||||||
$activeSessionId,
|
$activeSessionId,
|
||||||
$attentionSessionIds,
|
$attentionSessionIds,
|
||||||
|
|
@ -60,13 +63,14 @@ import {
|
||||||
$gatewayState,
|
$gatewayState,
|
||||||
$messages,
|
$messages,
|
||||||
$messagingSessions,
|
$messagingSessions,
|
||||||
$resumeFailedSessionId,
|
|
||||||
$resumeExhaustedSessionId,
|
$resumeExhaustedSessionId,
|
||||||
|
$resumeFailedSessionId,
|
||||||
$selectedStoredSessionId,
|
$selectedStoredSessionId,
|
||||||
$sessions,
|
$sessions,
|
||||||
$workingSessionIds,
|
$workingSessionIds,
|
||||||
CRON_SECTION_LIMIT,
|
CRON_SECTION_LIMIT,
|
||||||
getRecentlySettledSessionIds,
|
getRecentlySettledSessionIds,
|
||||||
|
getRememberedSessionId,
|
||||||
mergeSessionPage,
|
mergeSessionPage,
|
||||||
MESSAGING_SECTION_LIMIT,
|
MESSAGING_SECTION_LIMIT,
|
||||||
sessionPinId,
|
sessionPinId,
|
||||||
|
|
@ -81,6 +85,7 @@ import {
|
||||||
setMessagingPlatformTotals,
|
setMessagingPlatformTotals,
|
||||||
setMessagingSessions,
|
setMessagingSessions,
|
||||||
setMessagingTruncated,
|
setMessagingTruncated,
|
||||||
|
setRememberedSessionId,
|
||||||
setSessionProfileTotals,
|
setSessionProfileTotals,
|
||||||
setSessions,
|
setSessions,
|
||||||
setSessionsLoading,
|
setSessionsLoading,
|
||||||
|
|
@ -110,6 +115,8 @@ import { ModelPickerOverlay } from './model-picker-overlay'
|
||||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||||
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
|
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
|
||||||
import { RightSidebarPane } from './right-sidebar'
|
import { RightSidebarPane } from './right-sidebar'
|
||||||
|
import { FileActionDialogs } from './right-sidebar/file-actions'
|
||||||
|
import { ReviewPane } from './right-sidebar/review'
|
||||||
import { $terminalTakeover } from './right-sidebar/store'
|
import { $terminalTakeover } from './right-sidebar/store'
|
||||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||||
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||||
|
|
@ -215,6 +222,7 @@ export function DesktopController() {
|
||||||
const previewTarget = useStore($previewTarget)
|
const previewTarget = useStore($previewTarget)
|
||||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||||
const terminalTakeover = useStore($terminalTakeover)
|
const terminalTakeover = useStore($terminalTakeover)
|
||||||
|
const reviewOpen = useStore($reviewOpen)
|
||||||
const panesFlipped = useStore($panesFlipped)
|
const panesFlipped = useStore($panesFlipped)
|
||||||
const profileScope = useStore($profileScope)
|
const profileScope = useStore($profileScope)
|
||||||
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
|
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
|
||||||
|
|
@ -283,6 +291,36 @@ export function DesktopController() {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Remember the open chat so a relaunch reopens it instead of an empty new-chat.
|
||||||
|
useEffect(() => {
|
||||||
|
if (routedSessionId) {
|
||||||
|
setRememberedSessionId(routedSessionId)
|
||||||
|
}
|
||||||
|
}, [routedSessionId])
|
||||||
|
|
||||||
|
// Restore that chat once, on cold start only (we're at the new-chat route and
|
||||||
|
// haven't navigated yet). A dead/deleted id self-clears via the exhausted latch
|
||||||
|
// below, so we never boot-loop into an error screen.
|
||||||
|
const restoredLastSessionRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (restoredLastSessionRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restoredLastSessionRef.current = true
|
||||||
|
const last = getRememberedSessionId()
|
||||||
|
|
||||||
|
if (last && location.pathname === NEW_CHAT_ROUTE) {
|
||||||
|
navigate(sessionRoute(last), { replace: true })
|
||||||
|
}
|
||||||
|
}, [location.pathname, navigate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
|
||||||
|
setRememberedSessionId(null)
|
||||||
|
}
|
||||||
|
}, [resumeExhaustedSessionId])
|
||||||
|
|
||||||
// Notification click: the main process already focused the window; jump to its
|
// Notification click: the main process already focused the window; jump to its
|
||||||
// session. Notifications are tagged with the gateway *runtime* session id, but
|
// session. Notifications are tagged with the gateway *runtime* session id, but
|
||||||
// the chat route is keyed by the *stored* id — navigating with the runtime id
|
// the chat route is keyed by the *stored* id — navigating with the runtime id
|
||||||
|
|
@ -476,9 +514,9 @@ export function DesktopController() {
|
||||||
void refreshMessagingSessions()
|
void refreshMessagingSessions()
|
||||||
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
|
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
|
||||||
|
|
||||||
const loadMoreSessions = useCallback(() => {
|
const loadMoreSessions = useCallback(async () => {
|
||||||
bumpSessionsLimit()
|
bumpSessionsLimit()
|
||||||
void refreshSessions()
|
await refreshSessions()
|
||||||
}, [refreshSessions])
|
}, [refreshSessions])
|
||||||
|
|
||||||
// Another window mutated the shared session list (e.g. a chat started in the
|
// Another window mutated the shared session list (e.g. a chat started in the
|
||||||
|
|
@ -551,7 +589,7 @@ export function DesktopController() {
|
||||||
[activeSessionIdRef, updateSessionState]
|
[activeSessionIdRef, updateSessionState]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { changeSessionCwd, refreshProjectBranch } = useCwdActions({
|
const { refreshProjectBranch } = useCwdActions({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeSessionIdRef,
|
activeSessionIdRef,
|
||||||
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
|
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
|
||||||
|
|
@ -667,6 +705,7 @@ export function DesktopController() {
|
||||||
const {
|
const {
|
||||||
archiveSession,
|
archiveSession,
|
||||||
branchCurrentSession,
|
branchCurrentSession,
|
||||||
|
branchStoredSession,
|
||||||
createBackendSessionForSend,
|
createBackendSessionForSend,
|
||||||
openSettings,
|
openSettings,
|
||||||
removeSession,
|
removeSession,
|
||||||
|
|
@ -799,7 +838,10 @@ export function DesktopController() {
|
||||||
(path: null | string) => {
|
(path: null | string) => {
|
||||||
startFreshSessionDraft()
|
startFreshSessionDraft()
|
||||||
|
|
||||||
const target = path?.trim()
|
// A worktree lane carries its own path; the trunk "+" can be path-less (the
|
||||||
|
// main checkout is implicit), so fall back to the active project's root
|
||||||
|
// instead of no-op'ing on null — that was "+ on main does nothing".
|
||||||
|
const target = path?.trim() || resolveNewSessionCwd()
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return
|
return
|
||||||
|
|
@ -810,14 +852,50 @@ export function DesktopController() {
|
||||||
setCurrentCwd(target)
|
setCurrentCwd(target)
|
||||||
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||||
.then(info => {
|
.then(info => {
|
||||||
setCurrentCwd(info.cwd || target)
|
const resolved = info.cwd || target
|
||||||
|
|
||||||
|
setCurrentCwd(resolved)
|
||||||
setCurrentBranch(info.branch || '')
|
setCurrentBranch(info.branch || '')
|
||||||
|
|
||||||
|
// An EXPLICIT target (a worktree/lane path — e.g. just-created via
|
||||||
|
// "convert a branch" / "new worktree") drills the sidebar into that
|
||||||
|
// project so the new lane is visible at once. Without this, a brand-new
|
||||||
|
// worktree session is invisible from the all-projects overview (the
|
||||||
|
// live overlay skips `.worktrees` rows, and the session.info cwd-follow
|
||||||
|
// only fires on a same-session move, not a fresh session). The
|
||||||
|
// path-less trunk "+" keeps the current scope untouched.
|
||||||
|
if (path?.trim()) {
|
||||||
|
restoreWorktree(resolved)
|
||||||
|
void followActiveSessionCwd(resolved)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
},
|
},
|
||||||
[requestGateway, startFreshSessionDraft]
|
[requestGateway, startFreshSessionDraft]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Composer "branch off into a new worktree": the composer already created the
|
||||||
|
// worktree and cleared its draft; open a fresh session anchored to that tree,
|
||||||
|
// then prefill the task that kicked it off. startSessionInWorkspace owns the
|
||||||
|
// reset+cwd seed (it runs startFreshSessionDraft, which would otherwise stomp
|
||||||
|
// the cwd back to the default), so the prefill is dispatched right after — its
|
||||||
|
// deferred event lands once the fresh composer has remounted and rebound.
|
||||||
|
const startWorkSessionRequest = useStore($startWorkSessionRequest)
|
||||||
|
const lastStartWorkTokenRef = useRef(startWorkSessionRequest?.token ?? 0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!startWorkSessionRequest || startWorkSessionRequest.token === lastStartWorkTokenRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastStartWorkTokenRef.current = startWorkSessionRequest.token
|
||||||
|
startSessionInWorkspace(startWorkSessionRequest.path)
|
||||||
|
|
||||||
|
if (startWorkSessionRequest.draft) {
|
||||||
|
requestComposerInsert(startWorkSessionRequest.draft, { target: 'main' })
|
||||||
|
}
|
||||||
|
}, [startSessionInWorkspace, startWorkSessionRequest])
|
||||||
|
|
||||||
const handleSkinCommand = useSkinCommand()
|
const handleSkinCommand = useSkinCommand()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -981,6 +1059,7 @@ export function DesktopController() {
|
||||||
<ChatSidebar
|
<ChatSidebar
|
||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||||
|
onBranchSession={sessionId => void branchStoredSession(sessionId)}
|
||||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||||
onLoadMoreMessaging={loadMoreMessagingForPlatform}
|
onLoadMoreMessaging={loadMoreMessagingForPlatform}
|
||||||
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
||||||
|
|
@ -1031,6 +1110,7 @@ export function DesktopController() {
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
<PetGenerateOverlay />
|
<PetGenerateOverlay />
|
||||||
<SessionSwitcher />
|
<SessionSwitcher />
|
||||||
|
<FileActionDialogs />
|
||||||
|
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
|
|
@ -1158,14 +1238,43 @@ export function DesktopController() {
|
||||||
side={railSide}
|
side={railSide}
|
||||||
width={FILE_BROWSER_DEFAULT_WIDTH}
|
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||||
>
|
>
|
||||||
|
{/* Key on the project (cwd) so switching projects unmounts the old tree and
|
||||||
|
mounts a fresh one straight into its skeleton — no stale-then-blip. */}
|
||||||
<RightSidebarPane
|
<RightSidebarPane
|
||||||
|
key={currentCwd || 'no-cwd'}
|
||||||
onActivateFile={path => composer.insertContextPathInlineRef(path)}
|
onActivateFile={path => composer.insertContextPathInlineRef(path)}
|
||||||
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
|
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
|
||||||
onChangeCwd={changeSessionCwd}
|
|
||||||
/>
|
/>
|
||||||
</Pane>
|
</Pane>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const reviewPane = (
|
||||||
|
<Pane
|
||||||
|
defaultOpen
|
||||||
|
// The diff pane only makes sense in a workspace, so force it shut when the
|
||||||
|
// session is detached — "No diffs" then only ever shows inside a project,
|
||||||
|
// never as a second empty panel next to the file browser.
|
||||||
|
// Docked (wide): `reviewOpen` gates it. Narrow: drop `reviewOpen` from the
|
||||||
|
// gate so the pane stays mounted as a collapsed overlay — `toggleReview`
|
||||||
|
// then slides it in/out via the forced-reveal pin, exactly like ⌘B for the
|
||||||
|
// sidebar. Still requires a repo (no diffs to show otherwise).
|
||||||
|
disabled={!chatOpen || !currentCwd.trim() || (!narrowViewport && !reviewOpen)}
|
||||||
|
forceCollapsed={narrowViewport}
|
||||||
|
hoverReveal
|
||||||
|
id={REVIEW_PANE_ID}
|
||||||
|
key="review"
|
||||||
|
maxWidth={FILE_BROWSER_MAX_WIDTH}
|
||||||
|
minWidth={FILE_BROWSER_MIN_WIDTH}
|
||||||
|
// Mobile overlay sits at its min width — compact, doesn't bury the chat.
|
||||||
|
overlayWidth={FILE_BROWSER_MIN_WIDTH}
|
||||||
|
resizable
|
||||||
|
side={railSide}
|
||||||
|
width={FILE_BROWSER_DEFAULT_WIDTH}
|
||||||
|
>
|
||||||
|
<ReviewPane key={currentCwd || 'no-cwd'} />
|
||||||
|
</Pane>
|
||||||
|
)
|
||||||
|
|
||||||
const terminalPane = (
|
const terminalPane = (
|
||||||
<Pane
|
<Pane
|
||||||
defaultOpen
|
defaultOpen
|
||||||
|
|
@ -1258,6 +1367,7 @@ export function DesktopController() {
|
||||||
*/}
|
*/}
|
||||||
{panesFlipped ? fileBrowserPane : terminalPane}
|
{panesFlipped ? fileBrowserPane : terminalPane}
|
||||||
{previewPane}
|
{previewPane}
|
||||||
|
{reviewPane}
|
||||||
{panesFlipped ? terminalPane : fileBrowserPane}
|
{panesFlipped ? terminalPane : fileBrowserPane}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,9 @@ import { notify } from '@/store/notifications'
|
||||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||||
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
||||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||||
|
import { followActiveSessionCwd } from '@/store/projects'
|
||||||
import {
|
import {
|
||||||
|
$currentCwd,
|
||||||
setCurrentBranch,
|
setCurrentBranch,
|
||||||
setCurrentCwd,
|
setCurrentCwd,
|
||||||
setCurrentFastMode,
|
setCurrentFastMode,
|
||||||
|
|
@ -46,6 +48,7 @@ import {
|
||||||
setCurrentReasoningEffort,
|
setCurrentReasoningEffort,
|
||||||
setCurrentServiceTier,
|
setCurrentServiceTier,
|
||||||
setCurrentUsage,
|
setCurrentUsage,
|
||||||
|
setSessions,
|
||||||
setTurnStartedAt,
|
setTurnStartedAt,
|
||||||
setYoloActive
|
setYoloActive
|
||||||
} from '@/store/session'
|
} from '@/store/session'
|
||||||
|
|
@ -53,6 +56,7 @@ import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||||
import { setSessionTodos } from '@/store/todos'
|
import { setSessionTodos } from '@/store/todos'
|
||||||
import { recordToolDiff } from '@/store/tool-diffs'
|
import { recordToolDiff } from '@/store/tool-diffs'
|
||||||
|
import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events'
|
||||||
import type { RpcEvent } from '@/types/hermes'
|
import type { RpcEvent } from '@/types/hermes'
|
||||||
|
|
||||||
import type { ClientSessionState } from '../../types'
|
import type { ClientSessionState } from '../../types'
|
||||||
|
|
@ -339,6 +343,9 @@ export function useMessageStream({
|
||||||
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
|
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
|
||||||
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
||||||
const compactedTurnRef = useRef<Set<string>>(new Set())
|
const compactedTurnRef = useRef<Set<string>>(new Set())
|
||||||
|
// Last session we applied a session.info cwd for — lets us tell an agent
|
||||||
|
// relocating the SAME session (follow it) from a session switch (don't yank).
|
||||||
|
const lastCwdInfoSessionRef = useRef<null | string>(null)
|
||||||
|
|
||||||
const flushQueuedDeltas = useCallback(
|
const flushQueuedDeltas = useCallback(
|
||||||
(sessionId?: string) => {
|
(sessionId?: string) => {
|
||||||
|
|
@ -746,7 +753,20 @@ export function useMessageStream({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload?.cwd === 'string') {
|
if (typeof payload?.cwd === 'string') {
|
||||||
|
// The active session's agent can relocate itself (new repo/worktree
|
||||||
|
// via the terminal). When the SAME active session's cwd actually
|
||||||
|
// moves, follow it — refresh the project tree + scope so the sidebar
|
||||||
|
// tracks the live thread. A fresh selection (different session id)
|
||||||
|
// is a switch, not a move, so it refreshes data without yanking scope.
|
||||||
|
const cwdMoved = payload.cwd !== $currentCwd.get()
|
||||||
|
const sameSession = !!sessionId && sessionId === lastCwdInfoSessionRef.current
|
||||||
|
|
||||||
|
lastCwdInfoSessionRef.current = sessionId
|
||||||
setCurrentCwd(payload.cwd)
|
setCurrentCwd(payload.cwd)
|
||||||
|
|
||||||
|
if (cwdMoved && sameSession) {
|
||||||
|
void followActiveSessionCwd(payload.cwd)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload?.branch === 'string') {
|
if (typeof payload?.branch === 'string') {
|
||||||
|
|
@ -923,6 +943,16 @@ export function useMessageStream({
|
||||||
if (payload?.usage) {
|
if (payload?.usage) {
|
||||||
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
setCurrentUsage(current => ({ ...current, ...payload.usage }))
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'session.title') {
|
||||||
|
// Live auto-title push (titler runs async, after the turn's refresh).
|
||||||
|
const storedId = typeof payload?.session_id === 'string' ? payload.session_id : ''
|
||||||
|
const nextTitle = typeof payload?.title === 'string' ? payload.title.trim() : ''
|
||||||
|
|
||||||
|
if (storedId && nextTitle) {
|
||||||
|
setSessions(prev =>
|
||||||
|
prev.map(s => (s.id === storedId || s._lineage_root_id === storedId ? { ...s, title: nextTitle } : s))
|
||||||
|
)
|
||||||
|
}
|
||||||
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
|
} else if (event.type === 'tool.start' || event.type === 'tool.progress' || event.type === 'tool.generating') {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return
|
return
|
||||||
|
|
@ -959,6 +989,13 @@ export function useMessageStream({
|
||||||
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
||||||
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
|
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A file-mutating tool just finished — nudge the git-mirroring surfaces
|
||||||
|
// (coding rail, review pane, file tree) to refresh. Event-driven, not
|
||||||
|
// polled: fires exactly when the agent touches the tree.
|
||||||
|
if (payload && toolMayMutateFiles(payload)) {
|
||||||
|
notifyWorkspaceChanged()
|
||||||
|
}
|
||||||
} else if (SUBAGENT_EVENT_TYPES.has(event.type)) {
|
} else if (SUBAGENT_EVENT_TYPES.has(event.type)) {
|
||||||
if (sessionId && payload && !sessionInterrupted(sessionId)) {
|
if (sessionId && payload && !sessionInterrupted(sessionId)) {
|
||||||
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
|
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,10 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||||
|
|
||||||
interface HarnessHandle {
|
interface HarnessHandle {
|
||||||
cancelRun: () => Promise<void>
|
cancelRun: () => Promise<void>
|
||||||
restoreToMessage: (messageId: string) => Promise<void>
|
restoreToMessage: (
|
||||||
|
messageId: string,
|
||||||
|
target?: { text?: string; userOrdinal?: number | null }
|
||||||
|
) => Promise<void>
|
||||||
steerPrompt: (text: string) => Promise<boolean>
|
steerPrompt: (text: string) => Promise<boolean>
|
||||||
submitText: (
|
submitText: (
|
||||||
text: string,
|
text: string,
|
||||||
|
|
@ -642,17 +645,45 @@ describe('usePromptActions restoreToMessage', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores non-user targets and unknown ids without touching the gateway', async () => {
|
it('rejects non-user targets and unknown ids without touching the gateway', async () => {
|
||||||
const requestGateway = vi.fn(async () => ({}) as never)
|
const requestGateway = vi.fn(async () => ({}) as never)
|
||||||
|
|
||||||
let handle: HarnessHandle | null = null
|
let handle: HarnessHandle | null = null
|
||||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||||
|
|
||||||
await handle!.restoreToMessage('a1')
|
await expect(handle!.restoreToMessage('a1')).rejects.toThrow('Could not find the message to restore.')
|
||||||
await handle!.restoreToMessage('missing')
|
await expect(handle!.restoreToMessage('missing')).rejects.toThrow('Could not find the message to restore.')
|
||||||
|
|
||||||
expect(requestGateway).not.toHaveBeenCalled()
|
expect(requestGateway).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses the clicked runtime user ordinal when the rendered message id is stale', async () => {
|
||||||
|
const requestGateway = vi.fn(async () => ({}) as never)
|
||||||
|
|
||||||
|
let lastState: Record<string, unknown> = {}
|
||||||
|
let handle: HarnessHandle | null = null
|
||||||
|
render(
|
||||||
|
<Harness
|
||||||
|
onReady={h => (handle = h)}
|
||||||
|
onSeedState={state => (lastState = state)}
|
||||||
|
refreshSessions={async () => undefined}
|
||||||
|
requestGateway={requestGateway}
|
||||||
|
seedMessages={$messages.get()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
await handle!.restoreToMessage('runtime-user-id-not-in-store', {
|
||||||
|
text: 'first prompt',
|
||||||
|
userOrdinal: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(requestGateway).toHaveBeenCalledWith('prompt.submit', {
|
||||||
|
session_id: RUNTIME_SESSION_ID,
|
||||||
|
text: 'first prompt',
|
||||||
|
truncate_before_user_ordinal: 0
|
||||||
|
})
|
||||||
|
expect((lastState.messages as { id: string }[]).map(m => m.id)).toEqual(['u1'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('usePromptActions file attachment sync', () => {
|
describe('usePromptActions file attachment sync', () => {
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,11 @@ import {
|
||||||
updateComposerAttachment
|
updateComposerAttachment
|
||||||
} from '@/store/composer'
|
} from '@/store/composer'
|
||||||
import { resetSessionBackground } from '@/store/composer-status'
|
import { resetSessionBackground } from '@/store/composer-status'
|
||||||
import { clearPreviewArtifacts } from '@/store/preview-status'
|
|
||||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||||
import { setPetScale } from '@/store/pet-gallery'
|
import { setPetScale } from '@/store/pet-gallery'
|
||||||
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
|
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
|
||||||
|
import { clearPreviewArtifacts } from '@/store/preview-status'
|
||||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||||
import {
|
import {
|
||||||
$busy,
|
$busy,
|
||||||
|
|
@ -157,6 +157,13 @@ async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hard guard: at most one prompt.submit in flight per session. Every submit
|
||||||
|
// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels
|
||||||
|
// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated
|
||||||
|
// session whose first call hangs) let the SAME prompt launch several real turns
|
||||||
|
// at once (the "message stacked 5×" bug). Keyed by stored/active session id.
|
||||||
|
const _submitInFlight = new Set<string>()
|
||||||
|
|
||||||
function base64FromDataUrl(dataUrl: string): string {
|
function base64FromDataUrl(dataUrl: string): string {
|
||||||
const comma = dataUrl.indexOf(',')
|
const comma = dataUrl.indexOf(',')
|
||||||
|
|
||||||
|
|
@ -384,6 +391,31 @@ function visibleUserOrdinal(messages: readonly ChatMessage[], end: number): numb
|
||||||
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
|
return messages.slice(0, end).filter(m => m.role === 'user' && !m.hidden).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visibleUserIndexAtOrdinal(messages: readonly ChatMessage[], targetOrdinal: number): number {
|
||||||
|
let ordinal = 0
|
||||||
|
|
||||||
|
for (let index = 0; index < messages.length; index += 1) {
|
||||||
|
const message = messages[index]
|
||||||
|
|
||||||
|
if (message.role !== 'user' || message.hidden) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ordinal === targetOrdinal) {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
ordinal += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RestoreMessageTarget {
|
||||||
|
text?: string
|
||||||
|
userOrdinal?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export function usePromptActions({
|
export function usePromptActions({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeSessionIdRef,
|
activeSessionIdRef,
|
||||||
|
|
@ -599,6 +631,23 @@ export function usePromptActions({
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One submit in flight per session — drop any concurrent re-fire so a
|
||||||
|
// stalled turn can't stack the same prompt into multiple real turns.
|
||||||
|
const submitLockKey = selectedStoredSessionIdRef.current || activeSessionId || '__pending_new__'
|
||||||
|
|
||||||
|
if (_submitInFlight.has(submitLockKey)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_submitInFlight.add(submitLockKey)
|
||||||
|
let submitLockReleased = false
|
||||||
|
const releaseSubmitLock = () => {
|
||||||
|
if (!submitLockReleased) {
|
||||||
|
submitLockReleased = true
|
||||||
|
_submitInFlight.delete(submitLockKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
const buildUserMessage = (): ChatMessage => ({
|
const buildUserMessage = (): ChatMessage => ({
|
||||||
|
|
@ -609,6 +658,7 @@ export function usePromptActions({
|
||||||
})
|
})
|
||||||
|
|
||||||
const releaseBusy = () => {
|
const releaseBusy = () => {
|
||||||
|
releaseSubmitLock()
|
||||||
setMutableRef(busyRef, false)
|
setMutableRef(busyRef, false)
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
setAwaitingResponse(false)
|
setAwaitingResponse(false)
|
||||||
|
|
@ -750,6 +800,10 @@ export function usePromptActions({
|
||||||
clearComposerAttachments()
|
clearComposerAttachments()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Submit landed — the turn now runs (busy stays true), but the submit
|
||||||
|
// window is closed, so release the lock for the next (sequential) send.
|
||||||
|
releaseSubmitLock()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
releaseBusy()
|
releaseBusy()
|
||||||
|
|
@ -1644,55 +1698,78 @@ export function usePromptActions({
|
||||||
// mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that
|
// mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that
|
||||||
// user turn and everything after it from the session history, then the same
|
// user turn and everything after it from the session history, then the same
|
||||||
// text is submitted as a fresh turn. Callers confirm before invoking; errors
|
// text is submitted as a fresh turn. Callers confirm before invoking; errors
|
||||||
// are rethrown so the confirmation dialog can surface them inline.
|
// are rethrown so callers can surface failures. Idle rewinds submit directly:
|
||||||
// Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore
|
// interrupting an idle agent can leave a stale interrupt flag that cancels the
|
||||||
// can fire while a turn is streaming, interrupt the live turn first — the
|
// fresh turn. Live/stuck turns interrupt first, and a raced "session busy"
|
||||||
// cooperative interrupt takes a beat, so the shared busy-retry rides it out.
|
// response interrupts + retries through the shared busy gate.
|
||||||
const submitRewindPrompt = useCallback(
|
const submitRewindPrompt = useCallback(
|
||||||
async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
|
async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => {
|
||||||
if (wasRunning) {
|
const interrupt = async () => {
|
||||||
try {
|
try {
|
||||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort — the busy-retry below still gates the submit.
|
// Best-effort. The submit path still gates on the gateway state.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await withSessionBusyRetry(() =>
|
const submit = () =>
|
||||||
requestGateway('prompt.submit', {
|
requestGateway('prompt.submit', {
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
text,
|
text,
|
||||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||||
})
|
})
|
||||||
)
|
|
||||||
|
if (interruptFirst) {
|
||||||
|
await interrupt()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submit()
|
||||||
|
} catch (err) {
|
||||||
|
if (!isSessionBusyError(err)) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
await interrupt()
|
||||||
|
await withSessionBusyRetry(submit)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[requestGateway]
|
[requestGateway]
|
||||||
)
|
)
|
||||||
|
|
||||||
const restoreToMessage = useCallback(
|
const restoreToMessage = useCallback(
|
||||||
async (messageId: string) => {
|
async (messageId: string, target?: RestoreMessageTarget) => {
|
||||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return
|
throw new Error('No active session to restore.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = $messages.get()
|
const messages = $messages.get()
|
||||||
const sourceIndex = messages.findIndex(m => m.id === messageId)
|
const idIndex = messages.findIndex(m => m.id === messageId && m.role === 'user')
|
||||||
|
|
||||||
|
const fallbackIndex =
|
||||||
|
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||||
|
? -1
|
||||||
|
: visibleUserIndexAtOrdinal(messages, target.userOrdinal)
|
||||||
|
|
||||||
|
const sourceIndex = idIndex >= 0 ? idIndex : fallbackIndex
|
||||||
const source = messages[sourceIndex]
|
const source = messages[sourceIndex]
|
||||||
|
|
||||||
if (!source || source.role !== 'user') {
|
if (!source || source.role !== 'user') {
|
||||||
return
|
throw new Error('Could not find the message to restore.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = chatMessageText(source).trim()
|
const text = (chatMessageText(source).trim() || target?.text?.trim() || '').trim()
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return
|
throw new Error('Cannot restore an empty message.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasRunning = $busy.get()
|
const truncateBeforeUserOrdinal =
|
||||||
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex)
|
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||||
|
? visibleUserOrdinal(messages, sourceIndex)
|
||||||
|
: target.userOrdinal
|
||||||
|
|
||||||
// The turns we're discarding may have spawned todos and background
|
// The turns we're discarding may have spawned todos and background
|
||||||
// processes; they belong to the abandoned timeline, so wipe their status
|
// processes; they belong to the abandoned timeline, so wipe their status
|
||||||
|
|
@ -1716,12 +1793,21 @@ export function usePromptActions({
|
||||||
}))
|
}))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning)
|
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, busyRef.current || $busy.get())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// The rewind never landed (e.g. the gateway stayed busy past the retry
|
||||||
|
// deadline). Roll the optimistic truncation back to the full original
|
||||||
|
// history so the UI doesn't desync from what's persisted — leaving it
|
||||||
|
// truncated is what made subsequent sends look duplicative.
|
||||||
setMutableRef(busyRef, false)
|
setMutableRef(busyRef, false)
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
setAwaitingResponse(false)
|
setAwaitingResponse(false)
|
||||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
updateSessionState(sessionId, state => ({
|
||||||
|
...state,
|
||||||
|
busy: false,
|
||||||
|
awaitingResponse: false,
|
||||||
|
messages
|
||||||
|
}))
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1747,9 +1833,8 @@ export function usePromptActions({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sending an edit is a revert: rewind to this prompt and re-run with the
|
// Sending an edit is a revert: rewind to this prompt and re-run with the
|
||||||
// new text. It can fire mid-turn, so capture the live state — the submit
|
// new text. It can fire mid-turn; submitRewindPrompt always interrupts
|
||||||
// helper interrupts first when a turn is running.
|
// first, so a live turn is wound down before the resubmit.
|
||||||
const wasRunning = $busy.get()
|
|
||||||
|
|
||||||
// Failed turn: optimistic user msg never reached the gateway, so truncating
|
// Failed turn: optimistic user msg never reached the gateway, so truncating
|
||||||
// by ordinal would 422. Submit as a plain resend instead.
|
// by ordinal would 422. Submit as a plain resend instead.
|
||||||
|
|
@ -1782,7 +1867,12 @@ export function usePromptActions({
|
||||||
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
|
/no longer in session history|not in session history/i.test(err instanceof Error ? err.message : String(err))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitRewindPrompt(sessionId, text, isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex), wasRunning)
|
await submitRewindPrompt(
|
||||||
|
sessionId,
|
||||||
|
text,
|
||||||
|
isFailedTurn ? undefined : visibleUserOrdinal(messages, sourceIndex),
|
||||||
|
busyRef.current || $busy.get()
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let surfaced = err
|
let surfaced = err
|
||||||
|
|
||||||
|
|
@ -1797,10 +1887,13 @@ export function usePromptActions({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Roll the optimistic edit/truncation back to the original history so the
|
||||||
|
// UI stays in sync with what's persisted instead of stranding a partial
|
||||||
|
// timeline.
|
||||||
setMutableRef(busyRef, false)
|
setMutableRef(busyRef, false)
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
setAwaitingResponse(false)
|
setAwaitingResponse(false)
|
||||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false, messages }))
|
||||||
notifyError(surfaced, copy.editFailed)
|
notifyError(surfaced, copy.editFailed)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useEffect } from 'react'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { getSessionMessages } from '@/hermes'
|
import { getSessionMessages } from '@/hermes'
|
||||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
|
||||||
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
|
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
|
||||||
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
|
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
|
||||||
|
|
||||||
|
|
@ -283,147 +282,3 @@ describe('resumeSession failure recovery', () => {
|
||||||
expect(resumeParams).not.toHaveProperty('eager_build')
|
expect(resumeParams).not.toHaveProperty('eager_build')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
interface CacheHarnessProps {
|
|
||||||
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
|
|
||||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
|
||||||
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
|
|
||||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
|
||||||
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Harness that lets the test own the two cache maps so it can pre-seed a
|
|
||||||
// cross-wired runtime-id mapping and observe whether the warm fast-path trusts
|
|
||||||
// it. Mirrors the production wiring from use-session-state-cache.
|
|
||||||
function CacheHarness({
|
|
||||||
onReady,
|
|
||||||
requestGateway,
|
|
||||||
runtimeIdByStoredSessionIdRef,
|
|
||||||
selectedStoredSessionIdRef,
|
|
||||||
sessionStateByRuntimeIdRef
|
|
||||||
}: CacheHarnessProps) {
|
|
||||||
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
|
|
||||||
|
|
||||||
const actions = useSessionActions({
|
|
||||||
activeSessionId: null,
|
|
||||||
activeSessionIdRef: ref<string | null>(null),
|
|
||||||
busyRef: ref(false),
|
|
||||||
creatingSessionRef: ref(false),
|
|
||||||
ensureSessionState: () => ({}) as ClientSessionState,
|
|
||||||
getRouteToken: () => 'token',
|
|
||||||
navigate: vi.fn() as never,
|
|
||||||
requestGateway,
|
|
||||||
runtimeIdByStoredSessionIdRef,
|
|
||||||
selectedStoredSessionId: null,
|
|
||||||
selectedStoredSessionIdRef,
|
|
||||||
sessionStateByRuntimeIdRef,
|
|
||||||
syncSessionStateToView: vi.fn(),
|
|
||||||
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onReady(actions.resumeSession)
|
|
||||||
}, [actions.resumeSession, onReady])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientState = (storedSessionId: string | null): ClientSessionState => createClientSessionState(storedSessionId)
|
|
||||||
|
|
||||||
describe('resumeSession warm-cache mapping integrity', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup()
|
|
||||||
setResumeFailedSessionId(null)
|
|
||||||
setMessages([])
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects a cross-wired runtime mapping and falls through to a full resume', async () => {
|
|
||||||
// A recycled runtime id ('rt-recycled') is mapped to 'stored-A', but its
|
|
||||||
// cached state actually belongs to a DIFFERENT session ('stored-B') — the
|
|
||||||
// exact "open chat A, chat B loads" corruption a reaped/respawned pooled
|
|
||||||
// backend can leave behind.
|
|
||||||
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
|
|
||||||
current: new Map([['stored-A', 'rt-recycled']])
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
|
|
||||||
current: new Map([['rt-recycled', clientState('stored-B')]])
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null }
|
|
||||||
|
|
||||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
|
||||||
if (method === 'session.resume') {
|
|
||||||
return { session_id: 'rt-A-fresh', resumed: params?.session_id, messages: [], info: {} } as never
|
|
||||||
}
|
|
||||||
|
|
||||||
return {} as never
|
|
||||||
})
|
|
||||||
|
|
||||||
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
|
|
||||||
|
|
||||||
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
|
|
||||||
render(
|
|
||||||
<CacheHarness
|
|
||||||
onReady={r => (resume = r)}
|
|
||||||
requestGateway={requestGateway}
|
|
||||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
|
||||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
|
||||||
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
await waitFor(() => expect(resume).not.toBeNull())
|
|
||||||
await resume!('stored-A', true)
|
|
||||||
|
|
||||||
// The fast-path did NOT short-circuit on the cross-wired cache — the full
|
|
||||||
// resume RPC ran, for the session that was actually requested.
|
|
||||||
const resumeCalls = requestGateway.mock.calls.filter(([method]) => method === 'session.resume')
|
|
||||||
expect(resumeCalls.length).toBe(1)
|
|
||||||
expect(resumeCalls[0][1]).toMatchObject({ session_id: 'stored-A' })
|
|
||||||
|
|
||||||
// The corrupt mapping was purged so it can't mis-resolve again.
|
|
||||||
expect(runtimeIdByStoredSessionIdRef.current.has('stored-A')).toBe(false)
|
|
||||||
expect(sessionStateByRuntimeIdRef.current.has('rt-recycled')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('honours a warm cache entry whose stored id matches (no needless refetch)', async () => {
|
|
||||||
// Correctly-wired mapping: 'rt-A' <-> 'stored-A'. The fast-path should trust
|
|
||||||
// it and never reach session.resume (only the lightweight usage probe).
|
|
||||||
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
|
|
||||||
current: new Map([['stored-A', 'rt-A']])
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
|
|
||||||
current: new Map([['rt-A', clientState('stored-A')]])
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: null }
|
|
||||||
|
|
||||||
const requestGateway = vi.fn(async (method: string) => {
|
|
||||||
if (method === 'session.usage') {
|
|
||||||
return { input: 0, output: 0, total: 0 } as never
|
|
||||||
}
|
|
||||||
|
|
||||||
return {} as never
|
|
||||||
})
|
|
||||||
|
|
||||||
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
|
|
||||||
render(
|
|
||||||
<CacheHarness
|
|
||||||
onReady={r => (resume = r)}
|
|
||||||
requestGateway={requestGateway}
|
|
||||||
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
|
|
||||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
|
||||||
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
await waitFor(() => expect(resume).not.toBeNull())
|
|
||||||
await resume!('stored-A', true)
|
|
||||||
|
|
||||||
// Fast-path served the session from cache: no full resume RPC, mapping intact.
|
|
||||||
const methods = requestGateway.mock.calls.map(([method]) => method)
|
|
||||||
expect(methods).not.toContain('session.resume')
|
|
||||||
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { $pinnedSessionIds } from '@/store/layout'
|
||||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||||
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||||
|
import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'
|
||||||
import {
|
import {
|
||||||
$currentCwd,
|
$currentCwd,
|
||||||
$currentFastMode,
|
$currentFastMode,
|
||||||
|
|
@ -175,20 +176,37 @@ function reconcileResumeMessages(nextMessages: ChatMessage[], previousMessages:
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BranchMessage {
|
||||||
|
content: string
|
||||||
|
role: ChatMessage['role']
|
||||||
|
source: ChatMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// The copyable spine of a branch: user/assistant turns that carry text.
|
||||||
|
const toBranchMessages = (messages: ChatMessage[]): BranchMessage[] =>
|
||||||
|
messages
|
||||||
|
.map(message => ({ content: chatMessageText(message), role: message.role, source: message }))
|
||||||
|
.filter(({ content, role }) => content.trim() && (role === 'assistant' || role === 'user'))
|
||||||
|
|
||||||
function upsertOptimisticSession(
|
function upsertOptimisticSession(
|
||||||
created: SessionCreateResponse,
|
created: SessionCreateResponse,
|
||||||
id: string,
|
id: string,
|
||||||
title: string | null = null,
|
title: string | null = null,
|
||||||
preview: string | null = null
|
preview: string | null = null,
|
||||||
|
parentSessionId: string | null = null,
|
||||||
|
lastActive?: number
|
||||||
) {
|
) {
|
||||||
const now = Date.now() / 1000
|
const now = lastActive ?? Date.now() / 1000
|
||||||
// Stamp the profile the session was just created on (= the live gateway's
|
// Stamp the profile the session was just created on (= the live gateway's
|
||||||
// profile) so the scoped sidebar shows the new row immediately instead of
|
// profile) so the scoped sidebar shows the new row immediately instead of
|
||||||
// filtering it out as "default" until the aggregator re-fetches.
|
// filtering it out as "default" until the aggregator re-fetches.
|
||||||
const profileKey = normalizeProfileKey($activeGatewayProfile.get())
|
const profileKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||||
|
|
||||||
const session: SessionInfo = {
|
const session: SessionInfo = {
|
||||||
cwd: created.info?.cwd ?? null,
|
// Seed cwd so the grouped sidebar can place the new row in its repo/worktree
|
||||||
|
// lane immediately (the overlay groups by path); fall back to the workspace
|
||||||
|
// the session was just started in when the create response omits it.
|
||||||
|
cwd: created.info?.cwd ?? ($currentCwd.get().trim() || null),
|
||||||
ended_at: null,
|
ended_at: null,
|
||||||
id,
|
id,
|
||||||
input_tokens: 0,
|
input_tokens: 0,
|
||||||
|
|
@ -198,6 +216,7 @@ function upsertOptimisticSession(
|
||||||
message_count: created.message_count ?? created.messages?.length ?? 0,
|
message_count: created.message_count ?? created.messages?.length ?? 0,
|
||||||
model: created.info?.model ?? null,
|
model: created.info?.model ?? null,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
|
parent_session_id: parentSessionId,
|
||||||
preview,
|
preview,
|
||||||
profile: profileKey,
|
profile: profileKey,
|
||||||
source: 'tui',
|
source: 'tui',
|
||||||
|
|
@ -372,6 +391,16 @@ function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string }
|
||||||
setCurrentPersonality('')
|
setCurrentPersonality('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A "session genuinely doesn't exist" failure (deleted, or an id from a wiped /
|
||||||
|
// rotated backend) — the REST transcript 404s with `Session not found`. Distinct
|
||||||
|
// from a transient/wedged backend (ECONNREFUSED, timeout), which must still
|
||||||
|
// retry rather than discard the id.
|
||||||
|
function isSessionGoneError(err: unknown): boolean {
|
||||||
|
const message = err instanceof Error ? err.message : String(err ?? '')
|
||||||
|
|
||||||
|
return message.includes('404') || /session not found/i.test(message)
|
||||||
|
}
|
||||||
|
|
||||||
export function useSessionActions({
|
export function useSessionActions({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeSessionIdRef,
|
activeSessionIdRef,
|
||||||
|
|
@ -421,7 +450,10 @@ export function useSessionActions({
|
||||||
// is cleared.
|
// is cleared.
|
||||||
setCurrentServiceTier('')
|
setCurrentServiceTier('')
|
||||||
setYoloActive(false)
|
setYoloActive(false)
|
||||||
setCurrentCwd(workspaceCwdForNewSession())
|
// In a project → the repo's default-branch (main worktree) checkout; not in
|
||||||
|
// a project → detached. So cmd-n "knows" the project instead of inheriting
|
||||||
|
// whatever linked worktree the last session drifted into.
|
||||||
|
setCurrentCwd(resolveNewSessionCwd())
|
||||||
setCurrentBranch('')
|
setCurrentBranch('')
|
||||||
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
|
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
|
||||||
setFreshDraftReady(true)
|
setFreshDraftReady(true)
|
||||||
|
|
@ -591,34 +623,9 @@ export function useSessionActions({
|
||||||
// chat view drops the error state and shows the loader again.
|
// chat view drops the error state and shows the loader again.
|
||||||
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
|
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
|
||||||
|
|
||||||
// A warm cache entry is only trustworthy when it still BELONGS to the
|
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||||
// session being resumed. A pooled profile backend that gets idle-reaped
|
|
||||||
// and respawned (pruneSecondaryGateways) re-mints runtime ids, so a
|
|
||||||
// recycled id can resolve to a live-but-DIFFERENT session's cache entry.
|
|
||||||
// The session.usage 404 guard below only catches a fully-DEAD id — a
|
|
||||||
// recycled-live id 200s, so an unchecked hit paints the wrong transcript
|
|
||||||
// under the current route (the "open chat A, chat B loads" bug). On a
|
|
||||||
// mismatch the mapping is cross-wired: purge both sides and report a miss
|
|
||||||
// so the caller falls through to a full resume that rebinds a correct id.
|
|
||||||
const takeWarmCache = (): { runtimeId: string; state: ClientSessionState } | null => {
|
|
||||||
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
|
||||||
const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined
|
|
||||||
|
|
||||||
if (!runtimeId || !state) {
|
if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) {
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.storedSessionId !== storedSessionId) {
|
|
||||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
|
||||||
sessionStateByRuntimeIdRef.current.delete(runtimeId)
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return { runtimeId, state }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!takeWarmCache()) {
|
|
||||||
setActiveSessionId(null)
|
setActiveSessionId(null)
|
||||||
activeSessionIdRef.current = null
|
activeSessionIdRef.current = null
|
||||||
setMessages([])
|
setMessages([])
|
||||||
|
|
@ -637,14 +644,10 @@ export function useSessionActions({
|
||||||
|
|
||||||
await ensureGatewayProfile(sessionProfile)
|
await ensureGatewayProfile(sessionProfile)
|
||||||
|
|
||||||
// Re-check after the profile-resolve / gateway-swap awaits above: the
|
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||||
// cache may have changed, and takeWarmCache re-validates belongs-to and
|
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
|
||||||
// purges a cross-wired mapping before we trust the fast-path.
|
|
||||||
const warmHit = takeWarmCache()
|
|
||||||
|
|
||||||
if (warmHit) {
|
if (cachedRuntimeId && cachedState) {
|
||||||
const cachedRuntimeId = warmHit.runtimeId
|
|
||||||
const cachedState = warmHit.state
|
|
||||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||||
|
|
||||||
const cachedViewState =
|
const cachedViewState =
|
||||||
|
|
@ -743,7 +746,6 @@ export function useSessionActions({
|
||||||
...(watchWindow ? { lazy: true } : {}),
|
...(watchWindow ? { lazy: true } : {}),
|
||||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
...(sessionProfile ? { profile: sessionProfile } : {})
|
||||||
})
|
})
|
||||||
|
|
||||||
// The rejection is consumed by the `await` below; this guard only
|
// The rejection is consumed by the `await` below; this guard only
|
||||||
// keeps it from surfacing as unhandled while the prefetch settles.
|
// keeps it from surfacing as unhandled while the prefetch settles.
|
||||||
resumePromise.catch(() => undefined)
|
resumePromise.catch(() => undefined)
|
||||||
|
|
@ -829,6 +831,8 @@ export function useSessionActions({
|
||||||
// empty transcript. That is the exact state the thread loader latches on
|
// empty transcript. That is the exact state the thread loader latches on
|
||||||
// forever (messagesEmpty && !activeSessionId) with no recovery path —
|
// forever (messagesEmpty && !activeSessionId) with no recovery path —
|
||||||
// the "open in new window stays stuck loading, even after a nap" bug.
|
// the "open in new window stays stuck loading, even after a nap" bug.
|
||||||
|
let fallbackError: unknown = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
|
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
|
||||||
|
|
||||||
|
|
@ -837,14 +841,31 @@ export function useSessionActions({
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Fallback also failed: nothing to paint. Leave whatever messages are
|
// Fallback also failed: nothing to paint. Leave whatever messages are
|
||||||
// already shown and fall through to arm the resume-failure latch so
|
// already shown and fall through to arm the resume-failure latch so
|
||||||
// use-route-resume re-attempts the resume on the next render / window
|
// use-route-resume re-attempts the resume on the next render / window
|
||||||
// focus / gateway reconnect instead of stranding the loader.
|
// focus / gateway reconnect instead of stranding the loader.
|
||||||
|
fallbackError = e
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCurrentResume() && $messages.get().length === 0) {
|
if (!isCurrentResume()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The session is genuinely gone (deleted, or a stale id from a wiped /
|
||||||
|
// rotated backend): the resume RPC and the authoritative REST transcript
|
||||||
|
// both 404. There's nothing to recover — silently drop to a fresh draft
|
||||||
|
// instead of toasting an error and hot-looping the bounded retry on a
|
||||||
|
// permanently-dead id. (Booting straight into a no-longer-existent
|
||||||
|
// last-session id is the common trigger.)
|
||||||
|
if ($messages.get().length === 0 && isSessionGoneError(fallbackError)) {
|
||||||
|
startFreshSessionDraft(true)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($messages.get().length === 0) {
|
||||||
// Arm the self-heal ONLY when the window is still empty: the gateway
|
// Arm the self-heal ONLY when the window is still empty: the gateway
|
||||||
// resume rejected AND the REST fallback failed to paint a transcript.
|
// resume rejected AND the REST fallback failed to paint a transcript.
|
||||||
// That is the exact stranded state the loader latches on
|
// That is the exact stranded state the loader latches on
|
||||||
|
|
@ -873,93 +894,53 @@ export function useSessionActions({
|
||||||
runtimeIdByStoredSessionIdRef,
|
runtimeIdByStoredSessionIdRef,
|
||||||
selectedStoredSessionIdRef,
|
selectedStoredSessionIdRef,
|
||||||
sessionStateByRuntimeIdRef,
|
sessionStateByRuntimeIdRef,
|
||||||
|
startFreshSessionDraft,
|
||||||
syncSessionStateToView,
|
syncSessionStateToView,
|
||||||
updateSessionState
|
updateSessionState
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const branchCurrentSession = useCallback(
|
// Shared fork: create a child session seeded with `branchMessages`, linked to
|
||||||
async (messageId?: string): Promise<boolean> => {
|
// `parentStoredId` so it nests under its parent, then make it the active chat.
|
||||||
const sourceSessionId = activeSessionIdRef.current
|
const forkBranch = useCallback(
|
||||||
|
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
|
||||||
if (!sourceSessionId) {
|
|
||||||
notify({
|
|
||||||
kind: 'warning',
|
|
||||||
title: copy.nothingToBranch,
|
|
||||||
message: copy.branchNeedsChat
|
|
||||||
})
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (busyRef.current) {
|
|
||||||
notify({
|
|
||||||
kind: 'warning',
|
|
||||||
title: copy.sessionBusy,
|
|
||||||
message: copy.branchStopCurrent
|
|
||||||
})
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
creatingSessionRef.current = true
|
creatingSessionRef.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentMessages = $messages.get()
|
// No title: the backend auto-names the branch from its parent's lineage.
|
||||||
|
|
||||||
const targetIndex = messageId
|
|
||||||
? currentMessages.findIndex(message => message.id === messageId)
|
|
||||||
: currentMessages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
|
|
||||||
|
|
||||||
const branchStart = targetIndex >= 0 ? targetIndex : Math.max(currentMessages.length - 1, 0)
|
|
||||||
const branchEnd = targetIndex >= 0 ? targetIndex + 1 : currentMessages.length
|
|
||||||
|
|
||||||
const branchMessages = currentMessages
|
|
||||||
.slice(branchStart, branchEnd)
|
|
||||||
.map(message => ({
|
|
||||||
content: chatMessageText(message),
|
|
||||||
source: message,
|
|
||||||
role: message.role
|
|
||||||
}))
|
|
||||||
.filter(message => message.content.trim() && ['assistant', 'user'].includes(message.role))
|
|
||||||
|
|
||||||
if (!branchMessages.length) {
|
|
||||||
notify({
|
|
||||||
kind: 'warning',
|
|
||||||
title: copy.nothingToBranch,
|
|
||||||
message: copy.branchNoText
|
|
||||||
})
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
clearNotifications()
|
|
||||||
|
|
||||||
const cwd = $currentCwd.get().trim()
|
|
||||||
|
|
||||||
const branched = await requestGateway<SessionCreateResponse>('session.create', {
|
const branched = await requestGateway<SessionCreateResponse>('session.create', {
|
||||||
cols: 96,
|
cols: 96,
|
||||||
...(cwd && { cwd }),
|
...(cwd && { cwd }),
|
||||||
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
messages: branchMessages.map(({ content, role }) => ({ content, role })),
|
||||||
title: copy.branchTitle
|
...(parentStoredId && { parent_session_id: parentStoredId })
|
||||||
})
|
})
|
||||||
|
|
||||||
const routedSessionId = branched.stored_session_id ?? branched.session_id
|
const routedSessionId = branched.stored_session_id ?? branched.session_id
|
||||||
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
|
const preview = branchMessages.map(({ content }) => content).find(Boolean) ?? null
|
||||||
|
// Draft until submit: nest under the parent at the parent's recency so it
|
||||||
|
// doesn't bubble to the top until a real message lands (backend persists
|
||||||
|
// + auto-names it then). The selected row survives refreshes (sessionsToKeep).
|
||||||
|
const rows = $sessions.get()
|
||||||
|
const parent = parentStoredId ? rows.find(session => sessionMatchesStoredId(session, parentStoredId)) : null
|
||||||
|
const siblings = parentStoredId
|
||||||
|
? rows.filter(session => session.parent_session_id?.trim() === parentStoredId).length
|
||||||
|
: 0
|
||||||
|
|
||||||
setFreshDraftReady(false)
|
setFreshDraftReady(false)
|
||||||
upsertOptimisticSession(branched, routedSessionId, copy.branchTitle, preview)
|
upsertOptimisticSession(
|
||||||
|
branched,
|
||||||
|
routedSessionId,
|
||||||
|
copy.branchTitle(siblings + 1).toLowerCase(),
|
||||||
|
preview,
|
||||||
|
parentStoredId,
|
||||||
|
parent ? parent.last_active || parent.started_at : undefined
|
||||||
|
)
|
||||||
ensureSessionState(branched.session_id, routedSessionId)
|
ensureSessionState(branched.session_id, routedSessionId)
|
||||||
setActiveSessionId(branched.session_id)
|
setActiveSessionId(branched.session_id)
|
||||||
activeSessionIdRef.current = branched.session_id
|
activeSessionIdRef.current = branched.session_id
|
||||||
updateSessionState(
|
updateSessionState(
|
||||||
branched.session_id,
|
branched.session_id,
|
||||||
state => ({
|
state => ({ ...state, messages: branchMessages.map(({ source }) => source), busy: false, awaitingResponse: false }),
|
||||||
...state,
|
|
||||||
messages: branchMessages.map(({ source }) => source),
|
|
||||||
busy: false,
|
|
||||||
awaitingResponse: false
|
|
||||||
}),
|
|
||||||
routedSessionId
|
routedSessionId
|
||||||
)
|
)
|
||||||
setSelectedStoredSessionId(routedSessionId)
|
setSelectedStoredSessionId(routedSessionId)
|
||||||
|
|
@ -967,7 +948,6 @@ export function useSessionActions({
|
||||||
navigate(sessionRoute(routedSessionId))
|
navigate(sessionRoute(routedSessionId))
|
||||||
|
|
||||||
const runtimeInfo = applyRuntimeInfo(branched.info)
|
const runtimeInfo = applyRuntimeInfo(branched.info)
|
||||||
|
|
||||||
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
|
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
|
||||||
|
|
||||||
if (runtimeInfo) {
|
if (runtimeInfo) {
|
||||||
|
|
@ -985,17 +965,74 @@ export function useSessionActions({
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[activeSessionIdRef, copy, creatingSessionRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef, updateSessionState]
|
||||||
activeSessionIdRef,
|
)
|
||||||
busyRef,
|
|
||||||
copy,
|
// Branch the open chat — optionally from a specific message — off its live transcript.
|
||||||
creatingSessionRef,
|
const branchCurrentSession = useCallback(
|
||||||
ensureSessionState,
|
async (messageId?: string): Promise<boolean> => {
|
||||||
navigate,
|
if (!activeSessionIdRef.current) {
|
||||||
requestGateway,
|
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNeedsChat })
|
||||||
selectedStoredSessionIdRef,
|
|
||||||
updateSessionState
|
return false
|
||||||
]
|
}
|
||||||
|
|
||||||
|
if (busyRef.current) {
|
||||||
|
notify({ kind: 'warning', title: copy.sessionBusy, message: copy.branchStopCurrent })
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = $messages.get()
|
||||||
|
const at = messageId
|
||||||
|
? messages.findIndex(message => message.id === messageId)
|
||||||
|
: messages.findLastIndex(message => message.role === 'assistant' || message.role === 'user')
|
||||||
|
const start = at >= 0 ? at : Math.max(messages.length - 1, 0)
|
||||||
|
const end = at >= 0 ? at + 1 : messages.length
|
||||||
|
const branchMessages = toBranchMessages(messages.slice(start, end))
|
||||||
|
|
||||||
|
if (!branchMessages.length) {
|
||||||
|
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText })
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
clearNotifications()
|
||||||
|
|
||||||
|
return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim())
|
||||||
|
},
|
||||||
|
[activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Branch any listed session, not just the open one. Reads the target's stored
|
||||||
|
// transcript directly (no resume/active-session dependency), so it works on
|
||||||
|
// right-click and nests under its parent.
|
||||||
|
const branchStoredSession = useCallback(
|
||||||
|
async (storedSessionId: string, sessionProfile?: string | null): Promise<boolean> => {
|
||||||
|
clearNotifications()
|
||||||
|
|
||||||
|
const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId))
|
||||||
|
const profile = sessionProfile ?? stored?.profile
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureGatewayProfile(profile)
|
||||||
|
const { messages } = await getSessionMessages(storedSessionId, profile)
|
||||||
|
const branchMessages = toBranchMessages(toChatMessages(messages))
|
||||||
|
|
||||||
|
if (!branchMessages.length) {
|
||||||
|
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText })
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim())
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err, copy.branchFailed)
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[copy, forkBranch]
|
||||||
)
|
)
|
||||||
|
|
||||||
const removeSession = useCallback(
|
const removeSession = useCallback(
|
||||||
|
|
@ -1012,6 +1049,10 @@ export function useSessionActions({
|
||||||
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
|
const removedPinId = removed ? sessionPinId(removed) : storedSessionId
|
||||||
|
|
||||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||||
|
// Evict from the project tree's optimistic layer too (the backend snapshot
|
||||||
|
// still lists it until its next refresh), so grouped + flat views drop the
|
||||||
|
// row in lockstep.
|
||||||
|
tombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
|
||||||
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
||||||
// doesn't keep claiming the removed row is still on the server.
|
// doesn't keep claiming the removed row is still on the server.
|
||||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||||
|
|
@ -1040,6 +1081,7 @@ export function useSessionActions({
|
||||||
setSessionsTotal(prev => prev + 1)
|
setSessionsTotal(prev => prev + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
untombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
|
||||||
$pinnedSessionIds.set(previousPinned)
|
$pinnedSessionIds.set(previousPinned)
|
||||||
|
|
||||||
if (wasSelected) {
|
if (wasSelected) {
|
||||||
|
|
@ -1094,6 +1136,7 @@ export function useSessionActions({
|
||||||
|
|
||||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||||
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
setSessions(prev => prev.filter(session => !sessionMatchesStoredId(session, storedSessionId)))
|
||||||
|
tombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
|
||||||
// Archived sessions are hidden by the listSessions(min_messages=1) query
|
// Archived sessions are hidden by the listSessions(min_messages=1) query
|
||||||
// on the next refresh, so they count as "removed" for the load-more
|
// on the next refresh, so they count as "removed" for the load-more
|
||||||
// footer math.
|
// footer math.
|
||||||
|
|
@ -1119,6 +1162,7 @@ export function useSessionActions({
|
||||||
setSessionsTotal(prev => prev + 1)
|
setSessionsTotal(prev => prev + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
untombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
|
||||||
$pinnedSessionIds.set(previousPinned)
|
$pinnedSessionIds.set(previousPinned)
|
||||||
notifyError(err, copy.archiveFailed)
|
notifyError(err, copy.archiveFailed)
|
||||||
}
|
}
|
||||||
|
|
@ -1129,6 +1173,7 @@ export function useSessionActions({
|
||||||
return {
|
return {
|
||||||
archiveSession,
|
archiveSession,
|
||||||
branchCurrentSession,
|
branchCurrentSession,
|
||||||
|
branchStoredSession,
|
||||||
closeSettings,
|
closeSettings,
|
||||||
createBackendSessionForSend,
|
createBackendSessionForSend,
|
||||||
openSettings,
|
openSettings,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { $backgroundStatusBySession, dismissBackgroundProcess, reconcileBackgroundProcesses } from './composer-status'
|
import { $backgroundStatusBySession, dismissBackgroundProcess, reconcileBackgroundProcesses } from './composer-status'
|
||||||
|
|
||||||
|
|
@ -17,9 +17,17 @@ const items = () => $backgroundStatusBySession.get()[SID] ?? []
|
||||||
|
|
||||||
describe('reconcileBackgroundProcesses', () => {
|
describe('reconcileBackgroundProcesses', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
// Fake timers so the success self-clear (a real setTimeout) is deterministic
|
||||||
|
// and never leaks a pending timer between tests.
|
||||||
|
vi.useFakeTimers()
|
||||||
$backgroundStatusBySession.set({})
|
$backgroundStatusBySession.set({})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllTimers()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('maps registry entries to status items', () => {
|
it('maps registry entries to status items', () => {
|
||||||
reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0), exited('c', 1)])
|
reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0), exited('c', 1)])
|
||||||
|
|
||||||
|
|
@ -96,4 +104,50 @@ describe('reconcileBackgroundProcesses', () => {
|
||||||
|
|
||||||
expect($backgroundStatusBySession.get()).toEqual({})
|
expect($backgroundStatusBySession.get()).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The self-clear path calls dismissBackgroundProcess, which records the id in
|
||||||
|
// the module-level dismissed set; use a fresh session per test so that record
|
||||||
|
// can't bleed into another test's reconcile.
|
||||||
|
const itemsOf = (sid: string) => $backgroundStatusBySession.get()[sid] ?? []
|
||||||
|
|
||||||
|
it('self-clears a finished success after a short linger', () => {
|
||||||
|
reconcileBackgroundProcesses('sess-clear', [exited('a', 0)])
|
||||||
|
expect(itemsOf('sess-clear').map(i => i.id)).toEqual(['a'])
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5_000)
|
||||||
|
|
||||||
|
expect(itemsOf('sess-clear')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('self-clears a failed task too, but only after a longer linger', () => {
|
||||||
|
reconcileBackgroundProcesses('sess-fail', [exited('a', 1)])
|
||||||
|
|
||||||
|
// Still visible after the success window — the failure gets a longer one so
|
||||||
|
// its exit code stays readable.
|
||||||
|
vi.advanceTimersByTime(5_000)
|
||||||
|
expect(itemsOf('sess-fail').map(i => [i.id, i.state])).toEqual([['a', 'failed']])
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10_000)
|
||||||
|
expect(itemsOf('sess-fail')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never self-clears a still-running task', () => {
|
||||||
|
reconcileBackgroundProcesses('sess-run', [running('a')])
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000)
|
||||||
|
|
||||||
|
expect(itemsOf('sess-run').map(i => i.id)).toEqual(['a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arms the self-clear only once a task finishes', () => {
|
||||||
|
reconcileBackgroundProcesses('sess-arm', [running('a')])
|
||||||
|
vi.advanceTimersByTime(60_000)
|
||||||
|
// Still running after a minute — nothing scheduled yet.
|
||||||
|
expect(itemsOf('sess-arm').map(i => i.id)).toEqual(['a'])
|
||||||
|
|
||||||
|
reconcileBackgroundProcesses('sess-arm', [exited('a', 0)])
|
||||||
|
vi.advanceTimersByTime(5_000)
|
||||||
|
|
||||||
|
expect(itemsOf('sess-arm')).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { TodoItem, TodoStatus } from '@/lib/todos'
|
||||||
|
|
||||||
import { $gateway } from './gateway'
|
import { $gateway } from './gateway'
|
||||||
import { dispatchNativeNotification } from './native-notifications'
|
import { dispatchNativeNotification } from './native-notifications'
|
||||||
|
import { notifyError } from './notifications'
|
||||||
import { $subagentsBySession, type SubagentProgress } from './subagents'
|
import { $subagentsBySession, type SubagentProgress } from './subagents'
|
||||||
import { $todosBySession } from './todos'
|
import { $todosBySession } from './todos'
|
||||||
|
|
||||||
|
|
@ -38,6 +39,63 @@ export const $backgroundStatusBySession = atom<Record<string, ComposerStatusItem
|
||||||
// while, so without this every refresh would resurrect a dismissed row.
|
// while, so without this every refresh would resurrect a dismissed row.
|
||||||
const dismissedBySession = new Map<string, Set<string>>()
|
const dismissedBySession = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
// Finished tasks self-clear so the stack only ever holds running work. Success
|
||||||
|
// goes quick; failure lingers longer so its exit code stays readable (the output
|
||||||
|
// also lives in the transcript). A manual X still drops either at once.
|
||||||
|
const SUCCESS_LINGER_MS = 4_000
|
||||||
|
const FAILURE_LINGER_MS = 12_000
|
||||||
|
const autoClearTimers = new Map<string, Map<string, ReturnType<typeof setTimeout>>>()
|
||||||
|
|
||||||
|
function scheduleAutoDismiss(sid: string, id: string, delayMs: number) {
|
||||||
|
let timers = autoClearTimers.get(sid)
|
||||||
|
|
||||||
|
if (timers?.has(id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timers) {
|
||||||
|
timers = new Map()
|
||||||
|
autoClearTimers.set(sid, timers)
|
||||||
|
}
|
||||||
|
|
||||||
|
timers.set(
|
||||||
|
id,
|
||||||
|
setTimeout(() => {
|
||||||
|
autoClearTimers.get(sid)?.delete(id)
|
||||||
|
dismissBackgroundProcess(sid, id)
|
||||||
|
}, delayMs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelAutoDismiss(sid: string, id: string) {
|
||||||
|
const timers = autoClearTimers.get(sid)
|
||||||
|
|
||||||
|
if (!timers) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = timers.get(id)
|
||||||
|
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timers.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelAllAutoDismiss(sid: string) {
|
||||||
|
const timers = autoClearTimers.get(sid)
|
||||||
|
|
||||||
|
if (!timers) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const timer of timers.values()) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
|
||||||
|
autoClearTimers.delete(sid)
|
||||||
|
}
|
||||||
|
|
||||||
const subToItem = (s: SubagentProgress): ComposerStatusItem => ({
|
const subToItem = (s: SubagentProgress): ComposerStatusItem => ({
|
||||||
currentTool: s.currentTool,
|
currentTool: s.currentTool,
|
||||||
id: s.id,
|
id: s.id,
|
||||||
|
|
@ -201,6 +259,24 @@ export function reconcileBackgroundProcesses(sid: string, procs: GatewayProcessE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Arm the self-clear on every finished task (failures linger longer); cancel
|
||||||
|
// it for anything running again or gone from the snapshot.
|
||||||
|
const finishedDelay = new Map(
|
||||||
|
next
|
||||||
|
.filter(item => item.state !== 'running')
|
||||||
|
.map(item => [item.id, item.state === 'failed' ? FAILURE_LINGER_MS : SUCCESS_LINGER_MS])
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const [id, delay] of finishedDelay) {
|
||||||
|
scheduleAutoDismiss(sid, id, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of [...(autoClearTimers.get(sid)?.keys() ?? [])]) {
|
||||||
|
if (!finishedDelay.has(id)) {
|
||||||
|
cancelAutoDismiss(sid, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (next.length === prev.length && next.every((item, i) => item === prev[i])) {
|
if (next.length === prev.length && next.every((item, i) => item === prev[i])) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -227,6 +303,8 @@ export async function refreshBackgroundProcesses(sid: string): Promise<void> {
|
||||||
|
|
||||||
/** X on a finished row: drop it now and keep it dropped across refreshes. */
|
/** X on a finished row: drop it now and keep it dropped across refreshes. */
|
||||||
export function dismissBackgroundProcess(sid: string, id: string) {
|
export function dismissBackgroundProcess(sid: string, id: string) {
|
||||||
|
cancelAutoDismiss(sid, id)
|
||||||
|
|
||||||
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
|
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
|
||||||
dismissed.add(id)
|
dismissed.add(id)
|
||||||
dismissedBySession.set(sid, dismissed)
|
dismissedBySession.set(sid, dismissed)
|
||||||
|
|
@ -239,13 +317,17 @@ export function dismissBackgroundProcess(sid: string, id: string) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** X on a running row: kill the process for real, then drop the row. */
|
/** X on a running row: kill the process for real, THEN drop the row. Only drop
|
||||||
export function stopBackgroundProcess(sid: string, id: string) {
|
* on a confirmed kill — dismissing unconditionally (the old behavior) hid the
|
||||||
void $gateway
|
* row while the process lived on, stranding rogue tasks. On failure the row
|
||||||
.get()
|
* stays so the user can retry / see it didn't die. */
|
||||||
?.request('process.kill', { process_id: id, session_id: sid })
|
export async function stopBackgroundProcess(sid: string, id: string): Promise<void> {
|
||||||
.catch(() => undefined)
|
try {
|
||||||
dismissBackgroundProcess(sid, id)
|
await $gateway.get()?.request('process.kill', { process_id: id, session_id: sid })
|
||||||
|
dismissBackgroundProcess(sid, id)
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err, 'Could not stop the process')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -260,6 +342,8 @@ export function resetSessionBackground(sid: string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelAllAutoDismiss(sid)
|
||||||
|
|
||||||
const gateway = $gateway.get()
|
const gateway = $gateway.get()
|
||||||
const list = $backgroundStatusBySession.get()[sid] ?? []
|
const list = $backgroundStatusBySession.get()[sid] ?? []
|
||||||
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
|
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue