Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions apps/desktop/src/app/chat/session-tile-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { AppendMessage } from '@assistant-ui/react'
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { textPart } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { clearAllSessionStates, publishSessionState, setSessionTileDelegate } from '@/store/session-states'

import { MAIN_COMPOSER_SCOPE } from './composer/scope'
import { useSessionTileActions } from './session-tile-actions'

const requestGateway = vi.hoisted(() => vi.fn(async () => ({}) as never))

vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway })
}))

vi.mock('@/hermes', () => ({
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000,
setApiRequestProfile: vi.fn()
}))

const RUNTIME_ID = 'tile-runtime'
const STORED_ID = 'tile-stored'

const messages = [
{ id: 'u1', role: 'user' as const, parts: [textPart('first prompt')] },
{ id: 'a1', role: 'assistant' as const, parts: [textPart('first answer')] },
{ id: 'u2', role: 'user' as const, parts: [textPart('second prompt')] },
{ id: 'a2', role: 'assistant' as const, parts: [textPart('second answer')] }
]

function renderActions() {
let state = createClientSessionState(STORED_ID, messages)
publishSessionState(RUNTIME_ID, state)

setSessionTileDelegate({
archiveSession: async () => undefined,
branchSession: async () => undefined,
deleteSession: async () => undefined,
executeSlash: async () => undefined,
interruptSession: async () => undefined,
resumeTile: async () => RUNTIME_ID,
submitToSession: async () => undefined,
updateSession: (_runtimeId, updater) => {
state = updater(state)
publishSessionState(RUNTIME_ID, state)

return state
}
})

return renderHook(() =>
useSessionTileActions({ runtimeId: RUNTIME_ID, scope: MAIN_COMPOSER_SCOPE, storedSessionId: STORED_ID })
)
}

afterEach(() => {
cleanup()
clearAllSessionStates()
requestGateway.mockClear()
})

describe('session tile durable prompt binding', () => {
it('binds stop to the tile stored session id', async () => {
const { result } = renderActions()

await act(async () => result.current.cancelRun())

expect(requestGateway).toHaveBeenCalledWith('session.interrupt', {
expected_stored_session_id: STORED_ID,
session_id: RUNTIME_ID
})
})

it('binds reload to the tile stored session id', async () => {
const { result } = renderActions()

await act(async () => result.current.reloadFromMessage(null))

expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
expected_stored_session_id: STORED_ID,
session_id: RUNTIME_ID,
text: 'second prompt',
truncate_before_user_ordinal: 1
},
1_800_000
)
})

it('binds restore rewind to the tile stored session id', async () => {
const { result } = renderActions()

await act(async () => result.current.restoreToMessage('u1'))

expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
expected_stored_session_id: STORED_ID,
session_id: RUNTIME_ID,
text: 'first prompt',
truncate_before_user_ordinal: 0
},
1_800_000
)
})

it('binds edit rewind to the tile stored session id', async () => {
const { result } = renderActions()

await act(async () =>
result.current.editMessage({
parentId: null,
role: 'user',
sourceId: 'u2',
content: [{ type: 'text', text: 'edited second prompt' }]
} as unknown as AppendMessage)
)

expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
expected_stored_session_id: STORED_ID,
session_id: RUNTIME_ID,
text: 'edited second prompt',
truncate_before_user_ordinal: 1
},
1_800_000
)
})
})
14 changes: 11 additions & 3 deletions apps/desktop/src/app/chat/session-tile-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
clearClarifyRequest(undefined, sessionId)

try {
await requestGateway('session.interrupt', { session_id: sessionId })
await requestGateway('session.interrupt', {
expected_stored_session_id: storedIdRef.current,
session_id: sessionId
})
} catch (err) {
notifyError(err, copy.stopFailed)
}
Expand Down Expand Up @@ -251,7 +254,7 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
// the primary chat so the two can't diverge.
const submitRewind = useCallback(
(text: string, truncateOrdinal: number | undefined, interruptFirst: boolean) =>
runRewindSubmit(requestGateway, runtimeIdRef.current, text, truncateOrdinal, interruptFirst),
runRewindSubmit(requestGateway, runtimeIdRef.current, storedIdRef.current, text, truncateOrdinal, interruptFirst),
[requestGateway]
)

Expand All @@ -274,7 +277,12 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
try {
await requestGateway(
'prompt.submit',
{ session_id: runtimeIdRef.current, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
{
expected_stored_session_id: storedIdRef.current,
session_id: runtimeIdRef.current,
text: plan.text,
truncate_before_user_ordinal: plan.truncateOrdinal
},
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
} catch (err) {
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,13 @@ export function ProjectOverviewRow({
actions={
<>
{onNewSession && (
<WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />
<WorkspaceAddButton
label={s.newSessionIn(project.label)}
onClick={() => {
onEnter?.(project.id)
onNewSession(project.path)
}}
/>
)}
<ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { sessionTileDelegate } from '@/store/session-states'

import { useSessionTileDelegate } from './use-session-tile-delegate'

vi.mock('@/hermes', () => ({
getSessionMessages: vi.fn(async () => ({ messages: [] })),
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000,
setApiRequestProfile: vi.fn()
}))

afterEach(() => {
cleanup()
vi.restoreAllMocks()
})

describe('useSessionTileDelegate prompt binding', () => {
it('sends the caller-provided durable tile id on direct delegate submits', async () => {
const requestGateway = vi.fn(async () => ({}) as never)

renderHook(() =>
useSessionTileDelegate({
archiveSession: async () => undefined,
branchStoredSession: async () => undefined,
executeSlashCommand: async () => undefined,
removeSession: async () => undefined,
requestGateway,
runtimeIdByStoredSessionIdRef: { current: new Map() },
sessionStateByRuntimeIdRef: { current: new Map() },
updateSessionState: vi.fn()
} as unknown as Parameters<typeof useSessionTileDelegate>[0])
)

const submit = sessionTileDelegate()!.submitToSession as unknown as (
runtimeId: string,
storedSessionId: string,
text: string
) => Promise<void>

await submit('tile-runtime', 'tile-stored', 'delegate prompt')

expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
expected_stored_session_id: 'tile-stored',
session_id: 'tile-runtime',
text: 'delegate prompt'
},
1_800_000
)

await sessionTileDelegate()!.interruptSession('tile-runtime', 'tile-stored')

expect(requestGateway).toHaveBeenCalledWith('session.interrupt', {
expected_stored_session_id: 'tile-stored',
session_id: 'tile-runtime'
})
})
})
15 changes: 11 additions & 4 deletions apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,11 @@ export function useSessionTileDelegate({
executeSlash: async (rawCommand, sessionId) => {
await executeSlashCommand(rawCommand, { sessionId })
},
interruptSession: async runtimeId => {
await requestGateway('session.interrupt', { session_id: runtimeId })
interruptSession: async (runtimeId, storedSessionId) => {
await requestGateway('session.interrupt', {
expected_stored_session_id: storedSessionId,
session_id: runtimeId
})
},
resumeTile: async storedSessionId => {
const existing = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
Expand Down Expand Up @@ -90,8 +93,12 @@ export function useSessionTileDelegate({

return runtimeId
},
submitToSession: async (runtimeId, text) => {
await requestGateway('prompt.submit', { session_id: runtimeId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
submitToSession: async (runtimeId, storedSessionId, text) => {
await requestGateway(
'prompt.submit',
{ expected_stored_session_id: storedSessionId, session_id: runtimeId, text },
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
)
},
updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater)
})
Expand Down
39 changes: 10 additions & 29 deletions apps/desktop/src/app/contrib/wiring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile'
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects'
import { $startWorkSessionRequest } from '@/store/projects'
import {
$activeSessionId,
$connection,
Expand All @@ -48,8 +48,6 @@ import {
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentModelSource,
setCurrentProvider,
Expand Down Expand Up @@ -89,6 +87,7 @@ import { useRouteResume } from '../session/hooks/use-route-resume'
import { useSessionActions } from '../session/hooks/use-session-actions'
import { useSessionListActions } from '../session/hooks/use-session-list-actions'
import { useSessionStateCache } from '../session/hooks/use-session-state-cache'
import { startWorkspaceSession } from '../session/workspace-session-target'
import { useOverlayRouting } from '../shell/hooks/use-overlay-routing'
import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width'
import { titlebarControlsPosition } from '../shell/titlebar'
Expand Down Expand Up @@ -450,33 +449,15 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// also drills the sidebar into that project so the new lane is visible.
const startSessionInWorkspace = useCallback(
(path: null | string) => {
startFreshSessionDraft()

// 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.
const target = path?.trim() || resolveNewSessionCwd()

if (!target) {
return
}

setCurrentCwd(target)
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
.then(info => {
const resolved = info.cwd || target

setCurrentCwd(resolved)
setCurrentBranch(info.branch || '')

if (path?.trim()) {
restoreWorktree(resolved)
void followActiveSessionCwd(resolved)
}
})
.catch(() => undefined)
startWorkspaceSession({
activeSessionIdRef,
onExplicitWorkspace: restoreWorktree,
path,
requestGateway,
startFreshSessionDraft
})
},
[requestGateway, startFreshSessionDraft]
[activeSessionIdRef, requestGateway, startFreshSessionDraft]
)

// Composer "branch off into a new worktree": open a fresh session anchored
Expand Down
Loading