Skip to content
Merged
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
132 changes: 75 additions & 57 deletions apps/desktop/src/app/session/hooks/use-session-actions.test.tsx

Large diffs are not rendered by default.

88 changes: 55 additions & 33 deletions apps/desktop/src/app/session/hooks/use-session-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2360,20 +2360,54 @@ export function useSessionActions({

// No title: the backend auto-names the branch from its parent's lineage.
if (!createFlight) {
const branchParams = {
session_id: sourceSessionId,
...(branchCount !== undefined ? { count: branchCount } : {})
}

const createParams = {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(profile ? { profile } : {}),
...(parentStoredId && { parent_session_id: parentStoredId })
}

createFlight = (
sourceSessionId
? requestBranchGateway<SessionCreateResponse>('session.branch', {
session_id: sourceSessionId,
...(branchCount !== undefined ? { count: branchCount } : {})
})
: requestBranchGateway<SessionCreateResponse>('session.create', {
cols: 96,
source: 'desktop',
...(cwd && { cwd }),
...(profile ? { profile } : {}),
messages: branchMessages.map(({ content, role }) => ({ content, role })),
...(parentStoredId && { parent_session_id: parentStoredId })
? requestBranchGateway<SessionCreateResponse>(
branchCount === undefined ? 'session.branch_whole' : 'session.branch',
branchParams
).catch(err => {
if (!isMissingRpcMethod(err)) {
throw err
}

return requestBranchGateway<SessionCreateResponse>('session.branch', branchParams)
})
: branchMessages.length
? requestBranchGateway<SessionCreateResponse>('session.create', {
...createParams,
messages: branchMessages.map(({ content, role }) => ({ content, role }))
})
: requestBranchGateway<SessionCreateResponse>('session.branch_stored', createParams).catch(
async err => {
if (!isMissingRpcMethod(err)) {
throw err
}

const { messages } = await getAllSessionMessages(parentStoredId ?? '', ownerRoute ?? profile)

if (!messages.length) {
throw new Error('nothing to branch — send a message first')
}

return requestBranchGateway<SessionCreateResponse>('session.create', {
...createParams,
messages: messages.map(({ content, role }) => ({ content, role }))
})
}
)
).catch(err => {
// Drop the flight so a genuine retry re-issues the create; a
// resolved flight is cleared once the child is fully published.
Expand Down Expand Up @@ -2523,11 +2557,10 @@ export function useSessionActions({
const startingRouteToken = getRouteToken()
const startingCwd = $currentCwd.get().trim()

// The live atom may be a compacted model projection. Read the durable
// display projection before choosing the branch prefix so a whole-chat
// branch does not inherit only the summary/tail. If the backend is
// temporarily unavailable, retain the local snapshot and let the branch
// RPC make its own authoritative read.
// Message-level branches still need the local message id to choose their
// prefix. Whole-chat branches send only the parent identity below; the
// backend reads the durable display projection without materializing it in
// the renderer.
let authoritativeMessages: ChatMessage[] | null = null
const profile = await resolveSessionProfile(storedSessionId)

Expand All @@ -2537,7 +2570,7 @@ export function useSessionActions({
// whichever socket is active.
const ownerRoute = storedSessionId ? sessionOwnerRouteFromRow(cachedSessionRow(storedSessionId)) : undefined

if (storedSessionId) {
if (messageId && storedSessionId) {
try {
const persisted = await getAllSessionMessages(storedSessionId, ownerRoute ?? profile)
const hydrated = toChatMessages(persisted.messages)
Expand Down Expand Up @@ -2568,9 +2601,9 @@ export function useSessionActions({
return false
}

const branchMessages = selectBranchMessages(messages, authoritativeMessages, messageId)
const branchMessages = messageId ? selectBranchMessages(messages, authoritativeMessages, messageId) : []

if (!branchMessages.length) {
if (messageId && !branchMessages.length) {
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText })

return false
Expand Down Expand Up @@ -2623,21 +2656,10 @@ export function useSessionActions({
await ensureGatewayProfile(profile)
}

// Read the parent transcript from the backend that OWNS it. A bare
// profile scope resolves against the active connection, which for a
// foreign-owned parent holds no such session: the read comes back empty
// and the branch aborts as "nothing to branch" before any create.
const { messages } = await getAllSessionMessages(storedSessionId, ownerRoute ?? profile)
const branchMessages = toBranchMessages(toChatMessages(messages))

if (!branchMessages.length) {
notify({ kind: 'warning', title: copy.nothingToBranch, message: copy.branchNoText })

return false
}

// Ask the owning backend to read and copy the parent transcript. The
// renderer deliberately does not materialize the complete history.
return await forkBranch(
branchMessages,
[],
null,
stored?.id ?? storedSessionId,
stored?.cwd?.trim(),
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ export interface SessionCreateResponse {
info?: SessionRuntimeInfo
message_count?: number
messages?: SessionMessage[]
messages_omitted?: boolean
session_id: string
stored_session_id?: string
}
Expand Down
34 changes: 34 additions & 0 deletions apps/shared/src/gateway-contract.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2817,6 +2817,20 @@ export interface ToolLabel {
}
/** Which surface one inner call of a bridged ``tool_call`` runs on. */
export type ToolLabelKind = 'connector' | 'mcp' | 'tool'
export interface SessionBranchStoredParams {
profile?: string | null
parent_session_id: string
cols?: number | null
source?: string | null
cwd?: string | null
}
export interface SessionBranchStoredResult {
session_id: string
stored_session_id: string
message_count: number
messages_omitted: boolean
info: SessionLiveInfo
}
/** ``session_id`` is the STORED id (or an exact title); the reply's ``session_id`` is the runtime id. */
export interface SessionResumeParams {
session_id: string
Expand Down Expand Up @@ -3069,6 +3083,20 @@ export interface SessionBranchResult {
messages: TranscriptMessage[]
info: SessionLiveInfo
}
export interface SessionBranchWholeParams {
session_id: string
profile?: string | null
name?: string | null
}
export interface SessionBranchWholeResult {
session_id: string
stored_session_id: string
title: string
parent: string
message_count: number
messages_omitted: boolean
info: SessionLiveInfo
}
export interface SessionUndoParams {
session_id: string
profile?: string | null
Expand Down Expand Up @@ -4972,6 +5000,10 @@ export interface RpcMethods {
'session.active_list': { params: SessionActiveListParams; result: SessionActiveListResult }
/** Fork a live session into a new stored child that shares the parent's history so far. */
'session.branch': { params: SessionBranchParams; result: SessionBranchResult }
/** Whole-session branch of a stored parent: the owning backend reads and copies the transcript, which never crosses the wire (a separate method so an older gateway fails loudly, not with an empty branch). */
'session.branch_stored': { params: SessionBranchStoredParams; result: SessionBranchStoredResult }
/** session.branch of the whole history without echoing the copied transcript back. */
'session.branch_whole': { params: SessionBranchWholeParams; result: SessionBranchWholeResult }
/** Tear down a live session (its stored row stays resumable). */
'session.close': { params: SessionCloseParams; result: SessionCloseResult }
/** Manual /compress of an idle session, optionally focused on a topic. */
Expand Down Expand Up @@ -5279,6 +5311,8 @@ export const RPC_METHODS = [
'session.activate',
'session.active_list',
'session.branch',
'session.branch_stored',
'session.branch_whole',
'session.close',
'session.compress',
'session.context_breakdown',
Expand Down
Loading
Loading