Merge pull request #69750 from NousResearch/bb/desktop-fork-new-tab

fix(desktop): open branched chat in a new tab and switch to it
This commit is contained in:
brooklyn! 2026-07-22 22:17:17 -05:00 committed by GitHub
commit 5c4d358a7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 204 additions and 43 deletions

View file

@ -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,
@ -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'
@ -278,8 +280,25 @@ 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'
}
@ -287,12 +306,12 @@ function tileTitle(storedSessionId: string): string {
/** 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) }
}
@ -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)
@ -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,

View file

@ -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.
@ -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.

View file

@ -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()
@ -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
@ -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
}
/**

View file

@ -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')

View file

@ -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'
@ -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>
}) {
@ -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>()),
@ -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

View file

@ -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
@ -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 => ({
@ -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)
@ -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)
@ -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.

View file

@ -10,7 +10,7 @@
*/
import { useStore } from '@nanostores/react'
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react'
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
@ -178,6 +178,30 @@ export function TreeGroup({
const active = paneFor(activeId)
const isEmpty = node.panes.length === 0
// KEEP-ALIVE: every pane that has been ACTIVE in this zone stays mounted —
// an inactive tab merely hides (visibility), it does not unmount. Remounting
// on every tab switch re-measured and re-scrolled the content from scratch
// (the thread visibly layout-shifted each time a session tab was revisited).
// Lazy on purpose: a pane first mounts when first activated, so a
// boot-restored tab stack doesn't resume every session up front.
const everActivePanesRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!node.minimized && !isEmpty) {
everActivePanesRef.current.add(activeId)
}
// Prune panes that left the zone (closed / moved to another group), so a
// long-lived zone doesn't pin stale ids forever.
for (const id of everActivePanesRef.current) {
if (!node.panes.includes(id)) {
everActivePanesRef.current.delete(id)
}
}
})
const keptPanes = shown.filter(id => id === activeId || everActivePanesRef.current.has(id))
// ONE header style: the app's compact pane-header. DEFAULT is contextual —
// a single pane isn't a "tab", so its header auto-hides; a stack shows its
// chips. EXCEPTIONS force a lone pane to keep its header (tab + close X):
@ -478,18 +502,40 @@ export function TreeGroup({
</ZoneMenu>
)}
{/* Body: the active pane's contributed content, or the empty zone. */}
{/* Body: the zone's pane content every kept (ever-active) pane stays
mounted in an absolute layer; only the active one is visible.
`visibility` (not display) keeps the hidden pane's layout box, so
scroll positions and measurements survive the round-trip. */}
{!node.minimized && (
<div className="relative min-h-0 min-w-0 flex-1 overflow-auto">
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
{isEmpty ? (
<div className="grid h-full place-items-center">
{/* Same decode primitive as the CONNECTING boot overlay. */}
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
</div>
) : active?.render ? (
<ContribBoundary id={active.id}>{active.render()}</ContribBoundary>
) : (
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">{t.zones.missingPane(activeId)}</div>
keptPanes.map(paneId => {
const pane = paneFor(paneId)
const isActive = paneId === activeId
return (
<div
aria-hidden={!isActive || undefined}
className={cn('absolute inset-0 overflow-auto', !isActive && 'pointer-events-none invisible')}
key={paneId}
>
{pane?.render ? (
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>
) : (
isActive && (
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">
{t.zones.missingPane(paneId)}
</div>
)
)}
</div>
)
})
)}
</div>
)}

View file

@ -4,7 +4,7 @@ import { sessionProjectColor } from '@/app/chat/sidebar/projects/workspace-group
import { Codecs, persistentAtom } from '@/lib/persisted'
import { $projects } from '@/store/projects'
import { $sessions, sessionPinId } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import type { ProjectInfo, SessionInfo } from '@/types/hermes'
// Per-session color OVERRIDES — a user-picked color that wins over the inherited
// project color (#66565 layer 2). Desktop-local like pins, keyed by the DURABLE
@ -39,13 +39,21 @@ export function setSessionColorOverride(durableId: string, color: null | string)
//
// Precedence in one place: an explicit per-session override wins over the
// inherited project color. Agent-set color (#66565 layer 3) slots in here too.
function resolveSessionColor(
session: SessionInfo,
projects: ProjectInfo[],
overrides: Record<string, string>
): string | undefined {
return overrides[sessionPinId(session)] ?? sessionProjectColor(session, projects) ?? undefined
}
export const $sessionColorById = computed(
[$sessions, $projects, $sessionColorOverrides],
(sessions, projects, overrides) => {
const map: Record<string, string> = {}
for (const session of sessions) {
const color = overrides[sessionPinId(session)] ?? sessionProjectColor(session, projects)
const color = resolveSessionColor(session, projects, overrides)
if (color) {
map[session.id] = color
@ -57,7 +65,14 @@ export const $sessionColorById = computed(
)
// The color for a single session object (the tabs already hold the SessionInfo
// they render, so they resolve through the same map the sidebar reads).
// they render, so they resolve through the same map the sidebar reads). A row
// that isn't in `$sessions` — e.g. a project-tree session older than the
// paginated recents page, opened as a tab — misses the map, so fall back to the
// same resolver the map is built from.
export function sessionColorFor(session: null | SessionInfo | undefined): string | undefined {
return session ? $sessionColorById.get()[session.id] : undefined
if (!session) {
return undefined
}
return $sessionColorById.get()[session.id] ?? resolveSessionColor(session, $projects.get(), $sessionColorOverrides.get())
}