mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +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 { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
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 { cn } from '@/lib/utils'
|
||||
import {
|
||||
|
|
@ -209,7 +210,7 @@ function SubagentTree({ tree }: { tree: SubagentNode[] }) {
|
|||
if (tree.length === 0) {
|
||||
return (
|
||||
<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="max-w-md text-xs leading-relaxed text-muted-foreground/75">{t.agents.emptyDesc}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -88,7 +88,10 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
|||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => 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
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
onDismissError?: (messageId: string) => void
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
FILE_BROWSER_MIN_WIDTH,
|
||||
pinSession,
|
||||
PREVIEW_PANE_ID,
|
||||
restoreWorktree,
|
||||
setSidebarOverlayMounted,
|
||||
SIDEBAR_DEFAULT_WIDTH,
|
||||
SIDEBAR_MAX_WIDTH,
|
||||
|
|
@ -52,6 +53,8 @@ import {
|
|||
normalizeProfileKey,
|
||||
refreshActiveProfile
|
||||
} from '../store/profile'
|
||||
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '../store/projects'
|
||||
import { $reviewOpen, REVIEW_PANE_ID } from '../store/review'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
|
|
@ -60,13 +63,14 @@ import {
|
|||
$gatewayState,
|
||||
$messages,
|
||||
$messagingSessions,
|
||||
$resumeFailedSessionId,
|
||||
$resumeExhaustedSessionId,
|
||||
$resumeFailedSessionId,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
CRON_SECTION_LIMIT,
|
||||
getRecentlySettledSessionIds,
|
||||
getRememberedSessionId,
|
||||
mergeSessionPage,
|
||||
MESSAGING_SECTION_LIMIT,
|
||||
sessionPinId,
|
||||
|
|
@ -81,6 +85,7 @@ import {
|
|||
setMessagingPlatformTotals,
|
||||
setMessagingSessions,
|
||||
setMessagingTruncated,
|
||||
setRememberedSessionId,
|
||||
setSessionProfileTotals,
|
||||
setSessions,
|
||||
setSessionsLoading,
|
||||
|
|
@ -110,6 +115,8 @@ import { ModelPickerOverlay } from './model-picker-overlay'
|
|||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
|
||||
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 { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
|
||||
|
|
@ -215,6 +222,7 @@ export function DesktopController() {
|
|||
const previewTarget = useStore($previewTarget)
|
||||
const selectedStoredSessionId = useStore($selectedStoredSessionId)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const reviewOpen = useStore($reviewOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const profileScope = useStore($profileScope)
|
||||
// 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
|
||||
// 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
|
||||
|
|
@ -476,9 +514,9 @@ export function DesktopController() {
|
|||
void refreshMessagingSessions()
|
||||
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
|
||||
|
||||
const loadMoreSessions = useCallback(() => {
|
||||
const loadMoreSessions = useCallback(async () => {
|
||||
bumpSessionsLimit()
|
||||
void refreshSessions()
|
||||
await refreshSessions()
|
||||
}, [refreshSessions])
|
||||
|
||||
// Another window mutated the shared session list (e.g. a chat started in the
|
||||
|
|
@ -551,7 +589,7 @@ export function DesktopController() {
|
|||
[activeSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
const { changeSessionCwd, refreshProjectBranch } = useCwdActions({
|
||||
const { refreshProjectBranch } = useCwdActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
onSessionRuntimeInfo: updateActiveSessionRuntimeInfo,
|
||||
|
|
@ -667,6 +705,7 @@ export function DesktopController() {
|
|||
const {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
branchStoredSession,
|
||||
createBackendSessionForSend,
|
||||
openSettings,
|
||||
removeSession,
|
||||
|
|
@ -799,7 +838,10 @@ export function DesktopController() {
|
|||
(path: null | string) => {
|
||||
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) {
|
||||
return
|
||||
|
|
@ -810,14 +852,50 @@ export function DesktopController() {
|
|||
setCurrentCwd(target)
|
||||
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
.then(info => {
|
||||
setCurrentCwd(info.cwd || target)
|
||||
const resolved = info.cwd || target
|
||||
|
||||
setCurrentCwd(resolved)
|
||||
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)
|
||||
},
|
||||
[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 {
|
||||
|
|
@ -981,6 +1059,7 @@ export function DesktopController() {
|
|||
<ChatSidebar
|
||||
currentView={currentView}
|
||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||
onBranchSession={sessionId => void branchStoredSession(sessionId)}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreMessaging={loadMoreMessagingForPlatform}
|
||||
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
|
||||
|
|
@ -1031,6 +1110,7 @@ export function DesktopController() {
|
|||
<CommandPalette />
|
||||
<PetGenerateOverlay />
|
||||
<SessionSwitcher />
|
||||
<FileActionDialogs />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
|
|
@ -1158,14 +1238,43 @@ export function DesktopController() {
|
|||
side={railSide}
|
||||
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
|
||||
key={currentCwd || 'no-cwd'}
|
||||
onActivateFile={path => composer.insertContextPathInlineRef(path)}
|
||||
onActivateFolder={path => composer.insertContextPathInlineRef(path, true)}
|
||||
onChangeCwd={changeSessionCwd}
|
||||
/>
|
||||
</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 = (
|
||||
<Pane
|
||||
defaultOpen
|
||||
|
|
@ -1258,6 +1367,7 @@ export function DesktopController() {
|
|||
*/}
|
||||
{panesFlipped ? fileBrowserPane : terminalPane}
|
||||
{previewPane}
|
||||
{reviewPane}
|
||||
{panesFlipped ? terminalPane : fileBrowserPane}
|
||||
</AppShell>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ import { notify } from '@/store/notifications'
|
|||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import { followActiveSessionCwd } from '@/store/projects'
|
||||
import {
|
||||
$currentCwd,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentFastMode,
|
||||
|
|
@ -46,6 +48,7 @@ import {
|
|||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setCurrentUsage,
|
||||
setSessions,
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
|
@ -53,6 +56,7 @@ import { broadcastSessionsChanged } from '@/store/session-sync'
|
|||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { setSessionTodos } from '@/store/todos'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
import { notifyWorkspaceChanged, toolMayMutateFiles } from '@/store/workspace-events'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
|
@ -339,6 +343,9 @@ export function useMessageStream({
|
|||
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
|
||||
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
||||
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(
|
||||
(sessionId?: string) => {
|
||||
|
|
@ -746,7 +753,20 @@ export function useMessageStream({
|
|||
}
|
||||
|
||||
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)
|
||||
|
||||
if (cwdMoved && sameSession) {
|
||||
void followActiveSessionCwd(payload.cwd)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof payload?.branch === 'string') {
|
||||
|
|
@ -923,6 +943,16 @@ export function useMessageStream({
|
|||
if (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') {
|
||||
if (!sessionId) {
|
||||
return
|
||||
|
|
@ -959,6 +989,13 @@ export function useMessageStream({
|
|||
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
|
||||
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)) {
|
||||
if (sessionId && payload && !sessionInterrupted(sessionId)) {
|
||||
if (!nativeSubagentSessionsRef.current.has(sessionId)) {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
|||
|
||||
interface HarnessHandle {
|
||||
cancelRun: () => Promise<void>
|
||||
restoreToMessage: (messageId: string) => Promise<void>
|
||||
restoreToMessage: (
|
||||
messageId: string,
|
||||
target?: { text?: string; userOrdinal?: number | null }
|
||||
) => Promise<void>
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (
|
||||
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)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.restoreToMessage('a1')
|
||||
await handle!.restoreToMessage('missing')
|
||||
await expect(handle!.restoreToMessage('a1')).rejects.toThrow('Could not find the message to restore.')
|
||||
await expect(handle!.restoreToMessage('missing')).rejects.toThrow('Could not find the message to restore.')
|
||||
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -38,11 +38,11 @@ import {
|
|||
updateComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { resetSessionBackground } from '@/store/composer-status'
|
||||
import { clearPreviewArtifacts } from '@/store/preview-status'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
|
||||
import { clearPreviewArtifacts } from '@/store/preview-status'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$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 {
|
||||
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
|
||||
}
|
||||
|
||||
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({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
|
|
@ -599,6 +631,23 @@ export function usePromptActions({
|
|||
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 buildUserMessage = (): ChatMessage => ({
|
||||
|
|
@ -609,6 +658,7 @@ export function usePromptActions({
|
|||
})
|
||||
|
||||
const releaseBusy = () => {
|
||||
releaseSubmitLock()
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
|
|
@ -750,6 +800,10 @@ export function usePromptActions({
|
|||
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
|
||||
} catch (err) {
|
||||
releaseBusy()
|
||||
|
|
@ -1644,55 +1698,78 @@ export function usePromptActions({
|
|||
// mechanism — `prompt.submit` with `truncate_before_user_ordinal` drops that
|
||||
// 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
|
||||
// are rethrown so the confirmation dialog can surface them inline.
|
||||
// Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore
|
||||
// can fire while a turn is streaming, interrupt the live turn first — the
|
||||
// cooperative interrupt takes a beat, so the shared busy-retry rides it out.
|
||||
// are rethrown so callers can surface failures. Idle rewinds submit directly:
|
||||
// interrupting an idle agent can leave a stale interrupt flag that cancels the
|
||||
// fresh turn. Live/stuck turns interrupt first, and a raced "session busy"
|
||||
// response interrupts + retries through the shared busy gate.
|
||||
const submitRewindPrompt = useCallback(
|
||||
async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
|
||||
if (wasRunning) {
|
||||
async (sessionId: string, text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) => {
|
||||
const interrupt = async () => {
|
||||
try {
|
||||
await requestGateway('session.interrupt', { session_id: sessionId })
|
||||
} 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', {
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(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]
|
||||
)
|
||||
|
||||
const restoreToMessage = useCallback(
|
||||
async (messageId: string) => {
|
||||
async (messageId: string, target?: RestoreMessageTarget) => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
if (!sessionId) {
|
||||
return
|
||||
throw new Error('No active session to restore.')
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
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) {
|
||||
return
|
||||
throw new Error('Cannot restore an empty message.')
|
||||
}
|
||||
|
||||
const wasRunning = $busy.get()
|
||||
const truncateBeforeUserOrdinal = visibleUserOrdinal(messages, sourceIndex)
|
||||
const truncateBeforeUserOrdinal =
|
||||
target?.userOrdinal === null || target?.userOrdinal === undefined
|
||||
? visibleUserOrdinal(messages, sourceIndex)
|
||||
: target.userOrdinal
|
||||
|
||||
// The turns we're discarding may have spawned todos and background
|
||||
// processes; they belong to the abandoned timeline, so wipe their status
|
||||
|
|
@ -1716,12 +1793,21 @@ export function usePromptActions({
|
|||
}))
|
||||
|
||||
try {
|
||||
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, wasRunning)
|
||||
await submitRewindPrompt(sessionId, text, truncateBeforeUserOrdinal, busyRef.current || $busy.get())
|
||||
} 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)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: false,
|
||||
awaitingResponse: false,
|
||||
messages
|
||||
}))
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
|
@ -1747,9 +1833,8 @@ export function usePromptActions({
|
|||
}
|
||||
|
||||
// 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
|
||||
// helper interrupts first when a turn is running.
|
||||
const wasRunning = $busy.get()
|
||||
// new text. It can fire mid-turn; submitRewindPrompt always interrupts
|
||||
// first, so a live turn is wound down before the resubmit.
|
||||
|
||||
// Failed turn: optimistic user msg never reached the gateway, so truncating
|
||||
// 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))
|
||||
|
||||
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) {
|
||||
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)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false, messages }))
|
||||
notifyError(surfaced, copy.editFailed)
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useEffect } from 'react'
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getSessionMessages } from '@/hermes'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $activeGatewayProfile, $newChatProfile } from '@/store/profile'
|
||||
import { $currentCwd, $messages, $resumeFailedSessionId, setMessages, setResumeFailedSessionId } from '@/store/session'
|
||||
|
||||
|
|
@ -283,147 +282,3 @@ describe('resumeSession failure recovery', () => {
|
|||
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 { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { resolveNewSessionCwd, tombstoneSessions, untombstoneSessions } from '@/store/projects'
|
||||
import {
|
||||
$currentCwd,
|
||||
$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(
|
||||
created: SessionCreateResponse,
|
||||
id: string,
|
||||
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
|
||||
// profile) so the scoped sidebar shows the new row immediately instead of
|
||||
// filtering it out as "default" until the aggregator re-fetches.
|
||||
const profileKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
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,
|
||||
id,
|
||||
input_tokens: 0,
|
||||
|
|
@ -198,6 +216,7 @@ function upsertOptimisticSession(
|
|||
message_count: created.message_count ?? created.messages?.length ?? 0,
|
||||
model: created.info?.model ?? null,
|
||||
output_tokens: 0,
|
||||
parent_session_id: parentSessionId,
|
||||
preview,
|
||||
profile: profileKey,
|
||||
source: 'tui',
|
||||
|
|
@ -372,6 +391,16 @@ function applyStoredSessionPreviewRuntimeInfo(stored: { model?: null | string }
|
|||
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({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
|
|
@ -421,7 +450,10 @@ export function useSessionActions({
|
|||
// is cleared.
|
||||
setCurrentServiceTier('')
|
||||
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('')
|
||||
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
|
||||
setFreshDraftReady(true)
|
||||
|
|
@ -591,34 +623,9 @@ export function useSessionActions({
|
|||
// chat view drops the error state and shows the loader again.
|
||||
setResumeExhaustedSessionId(current => (current === storedSessionId ? null : current))
|
||||
|
||||
// A warm cache entry is only trustworthy when it still BELONGS to the
|
||||
// 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
|
||||
const warmRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
|
||||
if (!runtimeId || !state) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (state.storedSessionId !== storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
|
||||
sessionStateByRuntimeIdRef.current.delete(runtimeId)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return { runtimeId, state }
|
||||
}
|
||||
|
||||
if (!takeWarmCache()) {
|
||||
if (!warmRuntimeId || !sessionStateByRuntimeIdRef.current.get(warmRuntimeId)) {
|
||||
setActiveSessionId(null)
|
||||
activeSessionIdRef.current = null
|
||||
setMessages([])
|
||||
|
|
@ -637,14 +644,10 @@ export function useSessionActions({
|
|||
|
||||
await ensureGatewayProfile(sessionProfile)
|
||||
|
||||
// Re-check after the profile-resolve / gateway-swap awaits above: the
|
||||
// cache may have changed, and takeWarmCache re-validates belongs-to and
|
||||
// purges a cross-wired mapping before we trust the fast-path.
|
||||
const warmHit = takeWarmCache()
|
||||
const cachedRuntimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
const cachedState = cachedRuntimeId && sessionStateByRuntimeIdRef.current.get(cachedRuntimeId)
|
||||
|
||||
if (warmHit) {
|
||||
const cachedRuntimeId = warmHit.runtimeId
|
||||
const cachedState = warmHit.state
|
||||
if (cachedRuntimeId && cachedState) {
|
||||
const stored = $sessions.get().find(session => session.id === storedSessionId)
|
||||
|
||||
const cachedViewState =
|
||||
|
|
@ -743,7 +746,6 @@ export function useSessionActions({
|
|||
...(watchWindow ? { lazy: true } : {}),
|
||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
||||
})
|
||||
|
||||
// The rejection is consumed by the `await` below; this guard only
|
||||
// keeps it from surfacing as unhandled while the prefetch settles.
|
||||
resumePromise.catch(() => undefined)
|
||||
|
|
@ -829,6 +831,8 @@ export function useSessionActions({
|
|||
// empty transcript. That is the exact state the thread loader latches on
|
||||
// forever (messagesEmpty && !activeSessionId) with no recovery path —
|
||||
// the "open in new window stays stuck loading, even after a nap" bug.
|
||||
let fallbackError: unknown = null
|
||||
|
||||
try {
|
||||
const fallback = await getSessionMessages(storedSessionId, sessionProfile)
|
||||
|
||||
|
|
@ -837,14 +841,31 @@ export function useSessionActions({
|
|||
}
|
||||
|
||||
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Fallback also failed: nothing to paint. Leave whatever messages are
|
||||
// already shown and fall through to arm the resume-failure latch so
|
||||
// use-route-resume re-attempts the resume on the next render / window
|
||||
// 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
|
||||
// resume rejected AND the REST fallback failed to paint a transcript.
|
||||
// That is the exact stranded state the loader latches on
|
||||
|
|
@ -873,93 +894,53 @@ export function useSessionActions({
|
|||
runtimeIdByStoredSessionIdRef,
|
||||
selectedStoredSessionIdRef,
|
||||
sessionStateByRuntimeIdRef,
|
||||
startFreshSessionDraft,
|
||||
syncSessionStateToView,
|
||||
updateSessionState
|
||||
]
|
||||
)
|
||||
|
||||
const branchCurrentSession = useCallback(
|
||||
async (messageId?: string): Promise<boolean> => {
|
||||
const sourceSessionId = activeSessionIdRef.current
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Shared fork: create a child session seeded with `branchMessages`, linked to
|
||||
// `parentStoredId` so it nests under its parent, then make it the active chat.
|
||||
const forkBranch = useCallback(
|
||||
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
const currentMessages = $messages.get()
|
||||
|
||||
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()
|
||||
|
||||
// No title: the backend auto-names the branch from its parent's lineage.
|
||||
const branched = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
...(cwd && { cwd }),
|
||||
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 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)
|
||||
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)
|
||||
setActiveSessionId(branched.session_id)
|
||||
activeSessionIdRef.current = branched.session_id
|
||||
updateSessionState(
|
||||
branched.session_id,
|
||||
state => ({
|
||||
...state,
|
||||
messages: branchMessages.map(({ source }) => source),
|
||||
busy: false,
|
||||
awaitingResponse: false
|
||||
}),
|
||||
state => ({ ...state, messages: branchMessages.map(({ source }) => source), busy: false, awaitingResponse: false }),
|
||||
routedSessionId
|
||||
)
|
||||
setSelectedStoredSessionId(routedSessionId)
|
||||
|
|
@ -967,7 +948,6 @@ export function useSessionActions({
|
|||
navigate(sessionRoute(routedSessionId))
|
||||
|
||||
const runtimeInfo = applyRuntimeInfo(branched.info)
|
||||
|
||||
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
|
||||
|
||||
if (runtimeInfo) {
|
||||
|
|
@ -985,17 +965,74 @@ export function useSessionActions({
|
|||
}, 0)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
copy,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
]
|
||||
[activeSessionIdRef, copy, creatingSessionRef, ensureSessionState, navigate, requestGateway, selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
// Branch the open chat — optionally from a specific message — off its live transcript.
|
||||
const branchCurrentSession = useCallback(
|
||||
async (messageId?: string): Promise<boolean> => {
|
||||
if (!activeSessionIdRef.current) {
|
||||
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
|
||||
}
|
||||
|
||||
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(
|
||||
|
|
@ -1012,6 +1049,10 @@ export function useSessionActions({
|
|||
const removedPinId = removed ? sessionPinId(removed) : 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
|
||||
// doesn't keep claiming the removed row is still on the server.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
|
|
@ -1040,6 +1081,7 @@ export function useSessionActions({
|
|||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
untombstoneSessions([storedSessionId, removed?.id, removed?._lineage_root_id])
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
|
||||
if (wasSelected) {
|
||||
|
|
@ -1094,6 +1136,7 @@ export function useSessionActions({
|
|||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
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
|
||||
// on the next refresh, so they count as "removed" for the load-more
|
||||
// footer math.
|
||||
|
|
@ -1119,6 +1162,7 @@ export function useSessionActions({
|
|||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
untombstoneSessions([storedSessionId, archived?.id, archived?._lineage_root_id])
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
notifyError(err, copy.archiveFailed)
|
||||
}
|
||||
|
|
@ -1129,6 +1173,7 @@ export function useSessionActions({
|
|||
return {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
branchStoredSession,
|
||||
closeSettings,
|
||||
createBackendSessionForSend,
|
||||
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'
|
||||
|
||||
|
|
@ -17,9 +17,17 @@ const items = () => $backgroundStatusBySession.get()[SID] ?? []
|
|||
|
||||
describe('reconcileBackgroundProcesses', () => {
|
||||
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({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('maps registry entries to status items', () => {
|
||||
reconcileBackgroundProcesses(SID, [running('a'), exited('b', 0), exited('c', 1)])
|
||||
|
||||
|
|
@ -96,4 +104,50 @@ describe('reconcileBackgroundProcesses', () => {
|
|||
|
||||
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 { dispatchNativeNotification } from './native-notifications'
|
||||
import { notifyError } from './notifications'
|
||||
import { $subagentsBySession, type SubagentProgress } from './subagents'
|
||||
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.
|
||||
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 => ({
|
||||
currentTool: s.currentTool,
|
||||
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])) {
|
||||
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. */
|
||||
export function dismissBackgroundProcess(sid: string, id: string) {
|
||||
cancelAutoDismiss(sid, id)
|
||||
|
||||
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
|
||||
dismissed.add(id)
|
||||
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. */
|
||||
export function stopBackgroundProcess(sid: string, id: string) {
|
||||
void $gateway
|
||||
.get()
|
||||
?.request('process.kill', { process_id: id, session_id: sid })
|
||||
.catch(() => undefined)
|
||||
dismissBackgroundProcess(sid, id)
|
||||
/** X on a running row: kill the process for real, THEN drop the row. Only drop
|
||||
* on a confirmed kill — dismissing unconditionally (the old behavior) hid the
|
||||
* row while the process lived on, stranding rogue tasks. On failure the row
|
||||
* stays so the user can retry / see it didn't die. */
|
||||
export async function stopBackgroundProcess(sid: string, id: string): Promise<void> {
|
||||
try {
|
||||
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
|
||||
}
|
||||
|
||||
cancelAllAutoDismiss(sid)
|
||||
|
||||
const gateway = $gateway.get()
|
||||
const list = $backgroundStatusBySession.get()[sid] ?? []
|
||||
const dismissed = dismissedBySession.get(sid) ?? new Set<string>()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue