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
36 changes: 30 additions & 6 deletions apps/desktop/src/app/chat/session-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
import { createComposerAttachmentScope } from '@/store/composer'
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
import { $activeGatewayProfile } from '@/store/profile'
import { $projectTree } from '@/store/projects'
import { sessionAwaitingInput } from '@/store/prompts'
import {
$gatewayState,
Expand All @@ -54,6 +55,7 @@ import {
type SessionTile,
sessionTileDelegate
} from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'

import type { SessionDragPayload } from './composer/inline-refs'
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
Expand Down Expand Up @@ -278,21 +280,38 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
// Tile -> pane contribution sync (call once from the app root).
// ---------------------------------------------------------------------------

/** Resolve a tile's stored row: the recents list first, then the project
* tree. A session opened as a tab from a project group is often older than
* the paginated recents page, so it has no `$sessions` row at all until new
* activity lands it there — resolving through the tree keeps its tab titled
* and tinted instead of a grey "Session" placeholder. */
export function tileStoredRow(storedSessionId: string): SessionInfo | undefined {
const match = (s: SessionInfo) => sessionMatchesStoredId(s, storedSessionId)

return (
$sessions.get().find(match) ??
$projectTree
.get()
.flatMap(p => [...p.repos.flatMap(r => r.groups.flatMap(g => g.sessions)), ...(p.previewSessions ?? [])])
.find(match)
)
}

function tileTitle(storedSessionId: string): string {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
const stored = tileStoredRow(storedSessionId)

return stored ? sessionTitle(stored) : 'Session'
}

/** The tab's lead-dot color — the tile's session resolved through the SAME
* shared map the sidebar reads, so a row and its tab always agree. */
function tileAccent(storedSessionId: string): string | undefined {
return sessionColorFor($sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)))
return sessionColorFor(tileStoredRow(storedSessionId))
}

/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
function tileDragPayload(storedSessionId: string): SessionDragPayload {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
const stored = tileStoredRow(storedSessionId)

return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
}
Expand Down Expand Up @@ -381,9 +400,12 @@ export function SessionTabMenu({
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
tabPaneId: string
}) {
const sessions = useStore($sessions)
// Subscribe for reactivity; the row is read imperatively via tileStoredRow
// (which spans both sources), so the values themselves are unused here.
useStore($sessions)
useStore($projectTree)
const pinnedSessionIds = useStore($pinnedSessionIds)
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
const stored = tileStoredRow(storedSessionId)
const pinId = stored ? sessionPinId(stored) : storedSessionId
const pinned = pinnedSessionIds.includes(pinId)

Expand Down Expand Up @@ -440,7 +462,9 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
export const watchSessionTiles = paneMirror<SessionTile>({
source: $sessionTiles,
also: [$sessions, $sessionColorById],
// $projectTree: a tile whose session is older than the recents page resolves
// its title/accent through the tree, which loads after the tiles register.
also: [$sessions, $sessionColorById, $projectTree],
key: t => t.storedSessionId,
prefix: 'session-tile',
dir: t => t.dir,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,24 @@ describe('liveSessionProjectId', () => {
expect(id).toBe('p_app')
})

it('places a cwd-outside-root session under an explicit project matching either path', () => {
// A mid-session relocation (or a sibling worktree) leaves cwd outside the
// recorded repo root. An explicit folder match is still authoritative —
// only the auto-project (repo root) fallback needs cwd-under-root
// confidence. Match via the repo root...
expect(
liveSessionProjectId(makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [
makeProject('p_proj', ['/home/u/proj'])
])
).toBe('p_proj')
// ...and via the cwd.
expect(
liveSessionProjectId(makeSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [
makeProject('p_www', ['/www/elsewhere'])
])
).toBe('p_www')
})

it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => {
// The bug: a fresh Windows session drops into the overlay before the next
// backend refresh; case-sensitive matching missed its project until then.
Expand Down Expand Up @@ -553,6 +571,14 @@ describe('sessionProjectColor', () => {
expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff')
})

it('colors a cwd-outside-root session when an explicit project folder matches', () => {
// The backend tree groups such a row under the project; the client color
// derivation must agree instead of leaving the row (and its tab) grey.
const session = makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' })

expect(sessionProjectColor(session, [colored('p_proj', ['/home/u/proj'], '#4a9eff')])).toBe('#4a9eff')
})

it('returns null for a session that only maps to an auto repo root (no explicit project)', () => {
// liveSessionProjectId falls back to the repo root id, which is not a
// project row and therefore carries no color.
Expand Down
27 changes: 18 additions & 9 deletions apps/desktop/src/app/chat/sidebar/projects/workspace-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,11 @@ function isPathUnder(folder: string, target: string): boolean {
* the overview at once instead of waiting for the next backend refresh. Returns
* null only for sessions we genuinely can't place from the row alone: cwd-less,
* kanban-task worktrees (they fold into the kanban bucket), or a worktree that
* lives OUTSIDE the repo root (a sibling dir whose project can't be derived).
* lives OUTSIDE the repo root (a sibling dir) AND under no explicit project
* folder. An explicit-project folder match always places the row — even when
* the row's cwd sits outside its recorded repo root (a mid-session relocation,
* or a sibling worktree of a project repo), the folder match is authoritative;
* only the repo-root AUTO-project fallback needs cwd-under-root confidence.
*/
export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string {
const cwd = (session.cwd || '').trim()
Expand All @@ -372,13 +376,6 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
return null
}

// With a cwd present it must sit under the repo root (a sibling worktree
// outside the root can't be placed from the row alone); a root-only session
// skips this — the root IS the anchor.
if (cwd && !isPathUnder(repoRoot, cwd)) {
return null
}

let projectId = ''
let bestLen = -1

Expand All @@ -399,7 +396,19 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
}
}

return projectId || repoRoot
if (projectId) {
return projectId
}

// AUTO-project fallback (the repo root itself): with a cwd present it must
// sit under the repo root (a sibling worktree outside the root can't be
// placed from the row alone); a root-only session skips this — the root IS
// the anchor.
if (cwd && !isPathUnder(repoRoot, cwd)) {
return null
}

return repoRoot
}

/**
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,10 @@ function useSessionActions({
const workItems: ItemSpec[] = [
spec({
disabled: !onBranch,
icon: 'git-branch',
// Fork glyph to match the inline message action's GitFork icon
// (assistant-message.tsx). NB: this codicon font has no `git-fork`
// glyph (only `git-fork-private`); `repo-forked` is the fork icon.
icon: 'repo-forked',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')
Expand Down
44 changes: 43 additions & 1 deletion apps/desktop/src/app/session/hooks/use-session-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
setSelectedStoredSessionId,
setSessions
} from '@/store/session'
import { $sessionTiles } from '@/store/session-states'

import { sessionRoute } from '../../routes'
import type { ClientSessionState } from '../../types'
Expand Down Expand Up @@ -975,9 +976,11 @@ describe('resumeSession failure recovery', () => {
})

function BranchHarness({
navigate = vi.fn(),
onReady,
requestGateway
}: {
navigate?: ReturnType<typeof vi.fn>
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}) {
Expand All @@ -991,7 +994,7 @@ function BranchHarness({
ensureSessionState: () => ({}) as ClientSessionState,
getRouteToken: () => 'token',
getRoutedStoredSessionId: () => null,
navigate: vi.fn() as never,
navigate: navigate as never,
requestGateway,
resetViewSync: vi.fn(),
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
Expand All @@ -1013,9 +1016,48 @@ describe('branchStoredSession desktop source tagging', () => {
afterEach(() => {
cleanup()
setSessions([])
$sessionTiles.set([])
setSelectedStoredSessionId(null)
vi.restoreAllMocks()
})

it('opens the branch as a new tab and leaves the parent chat selected', async () => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.create') {
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
}

return {} as never
})

// Parent is the currently-open (primary) chat.
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
setSelectedStoredSessionId('stored-parent')
vi.mocked(getSessionMessages).mockResolvedValue({
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
session_id: 'stored-parent'
} as never)

const navigate = vi.fn()
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
render(
<BranchHarness
navigate={navigate}
onReady={branch => (branchStoredSession = branch)}
requestGateway={requestGateway}
/>
)
await waitFor(() => expect(branchStoredSession).not.toBeNull())

await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)

// The branch opened as its own tab...
expect($sessionTiles.get().some(tile => tile.storedSessionId === 'branch-stored')).toBe(true)
// ...without stealing the primary selection or navigating away from the parent.
expect($selectedStoredSessionId.get()).toBe('stored-parent')
expect(navigate).not.toHaveBeenCalledWith(sessionRoute('branch-stored'))
})

it('tags desktop branch sessions as desktop sessions', async () => {
let createParams: Record<string, unknown> | undefined

Expand Down
28 changes: 12 additions & 16 deletions apps/desktop/src/app/session/hooks/use-session-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,8 @@ export function useSessionActions({
)

// Shared fork: create a child session seeded with `branchMessages`, linked to
// `parentStoredId` so it nests under its parent, then make it the active chat.
// `parentStoredId` so it nests under its parent, then open it as its own tab
// and switch to it — the parent chat stays put (mirrors openNewSessionTile).
const forkBranch = useCallback(
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
creatingSessionRef.current = true
Expand Down Expand Up @@ -1067,8 +1068,6 @@ export function useSessionActions({
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 => ({
Expand All @@ -1079,9 +1078,6 @@ export function useSessionActions({
}),
routedSessionId
)
setSelectedStoredSessionId(routedSessionId)
selectedStoredSessionIdRef.current = routedSessionId
navigate(sessionRoute(routedSessionId))

const runtimeInfo = applyRuntimeInfo(branched.info)
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
Expand All @@ -1090,6 +1086,15 @@ export function useSessionActions({
updateSessionState(branched.session_id, state => ({ ...state, ...runtimeInfo }), routedSessionId)
}

// Open the branch as its own tab and switch to it, leaving the parent
// chat exactly where it is. Prime the tile with the create runtime so it
// skips a redundant resume. Do NOT select it as the primary session
// first — openSessionTile no-ops when the id is already primary.
openSessionTile(routedSessionId, 'center')
patchSessionTile(routedSessionId, { runtimeId: branched.session_id })
revealTreePane(`session-tile:${routedSessionId}`)
broadcastSessionsChanged()

return true
} catch (err) {
notifyError(err, copy.branchFailed)
Expand All @@ -1101,16 +1106,7 @@ export function useSessionActions({
}, 0)
}
},
[
activeSessionIdRef,
copy,
creatingSessionRef,
ensureSessionState,
navigate,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
]
[copy, creatingSessionRef, ensureSessionState, requestGateway, updateSessionState]
)

// Branch the open chat — optionally from a specific message — off its live transcript.
Expand Down
Loading
Loading