feat(desktop): pin Home to the top of the project sidebar

Home leads the overview above the active project and outside any drag
order, drills in to a flat chat list (it has no repo or worktree
structure), and overlays live sessions so a brand-new detached chat
appears instantly. Starting a chat from inside Home stays detached
instead of picking up the configured default project dir. Rename and
delete are hidden — there's no record behind the row.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 16:05:19 -05:00
parent ef0f4763e3
commit 5a5d7b9386
11 changed files with 251 additions and 59 deletions

View file

@ -615,7 +615,13 @@ export function ChatSidebar({
const sorted = sortProjectsForOverview(
projectTree
.filter(node => !(node.isAuto && dismissed.has(node.id)))
.map(project => ({ ...project, repos: orderRepos(project.repos) })),
.map(project => ({
...project,
// Home is synthetic, so its name is ours to translate — every other
// label is a repo basename or a name the user typed.
label: project.isNoProject ? s.projects.home : project.label,
repos: orderRepos(project.repos)
})),
activeProjectId
)
@ -623,7 +629,7 @@ export function ChatSidebar({
// (default) returns `sorted` untouched; projects the user hasn't ordered yet
// keep their sorted position rather than jumping the hand-picked list.
return orderProjectsByIds(sorted, projectOrderIds)
}, [showAllProfiles, projectTree, dismissedAutoProjects, orderRepos, activeProjectId, projectOrderIds])
}, [showAllProfiles, projectTree, dismissedAutoProjects, orderRepos, activeProjectId, projectOrderIds, s])
// The overview only renders in grouped mode; the model stays live regardless
// so scoping is consistent across views.
@ -685,7 +691,9 @@ export function ChatSidebar({
// The live-session overlay (creates/evictions) is applied per-repo in
// RepoFlatSection, AFTER the visual git-worktree lanes are merged in (so
// out-of-tree worktrees can be placed). Here we just order the snapshot.
return { ...hydrated, repos: orderRepos(hydrated.repos) }
// The label comes from the overview node either way — that's the model's
// presentation copy (Home is translated there), not the raw payload's.
return { ...hydrated, label: overviewEnteredProject.label, repos: orderRepos(hydrated.repos) }
}, [overviewEnteredProject, enteredProjectTree, orderRepos])
// Overlay live `$sessions` onto the entered project so a just-created session
@ -785,7 +793,7 @@ export function ChatSidebar({
// Preview rows come from the backend tree (each project carries its
// most-recent sessions), overlaid with live $sessions so a just-created
// session shows under its project instantly (and with its working arc),
// matching the flat Recents list. Keyed by project path for the rows.
// matching the flat Recents list. Keyed by project id for the rows.
const overviewPreviews = useMemo<Record<string, SessionInfo[]>>(
() => overlayLivePreviews(projectOverview ?? [], agentSessions, projects, PROJECT_PREVIEW_COUNT, removedSessionIds),
[projectOverview, agentSessions, projects, removedSessionIds]
@ -1304,12 +1312,15 @@ export function ChatSidebar({
{enteredProject.path && (
<StartWorkButton onStarted={onNewSessionInWorkspace} repoPath={enteredProject.path} />
)}
<ProjectMenu
isActive={enteredProject.id === activeProjectId}
onExitScope={exitProjectScope}
project={enteredProject}
scoped
/>
{/* Home has no folder and no record to rename, theme, or delete. */}
{!enteredProject.isNoProject && (
<ProjectMenu
isActive={enteredProject.id === activeProjectId}
onExitScope={exitProjectScope}
project={enteredProject}
scoped
/>
)}
<div className="grid size-6 place-items-center">
<Tip label={s.showProjects}>
<Button

View file

@ -55,6 +55,12 @@ export function EnteredProjectContent({
return null
}
// Home's rows aren't anchored to a folder, so there's no repo or worktree
// structure to show — just the chats.
if (project.isNoProject) {
return <>{renderRows(project.repos.flatMap(repo => repo.groups.flatMap(group => group.sessions)))}</>
}
const single = project.repos.length === 1
return (

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { orderProjectsByIds } from './model'
import type { SidebarProjectTree } from './workspace-groups'
import { orderProjectsByIds, sortProjectsForOverview } from './model'
import { NO_PROJECT_ID, type SidebarProjectTree } from './workspace-groups'
function makeProject(id: string, sessionCount: number): SidebarProjectTree {
return {
@ -16,6 +16,13 @@ function makeProject(id: string, sessionCount: number): SidebarProjectTree {
}
}
const home = (): SidebarProjectTree => ({
...makeProject(NO_PROJECT_ID, 2),
isAuto: false,
isNoProject: true,
path: null
})
const ids = (projects: SidebarProjectTree[]) => projects.map(project => project.id)
describe('orderProjectsByIds', () => {
@ -53,4 +60,19 @@ describe('orderProjectsByIds', () => {
expect(ids(orderProjectsByIds(projects, ['gone', 'a']))).toEqual(['a'])
})
it('keeps Home on top of a hand-picked order', () => {
const projects = [makeProject('a', 1), home(), makeProject('b', 1)]
expect(ids(orderProjectsByIds(projects, ['b', 'a']))).toEqual([NO_PROJECT_ID, 'b', 'a'])
})
})
describe('sortProjectsForOverview', () => {
it('puts Home above the active project', () => {
const active = { ...makeProject('active', 5), isAuto: false }
const projects = [makeProject('scanned', 0), active, home()]
expect(ids(sortProjectsForOverview(projects, 'active'))).toEqual([NO_PROJECT_ID, 'active', 'scanned'])
})
})

View file

@ -45,11 +45,18 @@ const projectActivityTime = (project: SidebarProjectTree): number =>
export const latestProjectSessions = (project: SidebarProjectTree, limit: number): SessionInfo[] =>
[...projectSessions(project)].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
// Home is a fixture, not a project: it always leads the overview, above the
// active project and outside any hand-picked order.
const homeFirst = (projects: SidebarProjectTree[]): SidebarProjectTree[] =>
projects[0]?.isNoProject || !projects.some(project => project.isNoProject)
? projects
: [...projects.filter(project => project.isNoProject), ...projects.filter(project => !project.isNoProject)]
export function sortProjectsForOverview(
projects: SidebarProjectTree[],
activeProjectId: null | string
): SidebarProjectTree[] {
return [...projects].sort((a, b) => {
const sorted = [...projects].sort((a, b) => {
const aActive = Boolean(activeProjectId && a.id === activeProjectId && !a.isAuto)
const bActive = Boolean(activeProjectId && b.id === activeProjectId && !b.isAuto)
@ -73,6 +80,8 @@ export function sortProjectsForOverview(
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
)
})
return homeFirst(sorted)
}
// Layer the user's manual drag-order over the deterministic sort.
@ -98,14 +107,14 @@ export function orderProjectsByIds(projects: SidebarProjectTree[], orderIds: str
const fresh = projects.filter(project => !seen.has(project.id))
if (!fresh.length) {
return ordered
return homeFirst(ordered)
}
return [
return homeFirst([
...fresh.filter(project => project.sessionCount > 0),
...ordered,
...fresh.filter(project => project.sessionCount <= 0)
]
])
}
// Project drill-in lanes are git-driven: source them from `git worktree list` so

View file

@ -66,4 +66,12 @@ describe('ProjectOverviewRow', () => {
expect(screen.queryByRole('button', { name: 'Toggle Test D sessions' })).toBeNull()
})
it('drops the "new session" add button on Home, which has no folder to start in', () => {
const home = { id: '__no_project__', isNoProject: true, label: 'Home' } as unknown as SidebarProjectTree
render(<ProjectOverviewRow onNewSession={vi.fn()} project={home} />)
expect(screen.queryByRole('button', { name: 'New session in Home' })).toBeNull()
})
})

View file

@ -28,7 +28,7 @@ import { WorkspaceAddButton } from './workspace-header'
// A bare color dot (no icon) or an icon glyph — tinted by `color` when set, else
// the lead's default tertiary. The glyph wrapper centers + caps size either way.
export function projectIcon({ color, icon }: SidebarProjectTree) {
export function projectIcon({ color, icon, isNoProject }: SidebarProjectTree) {
if (color && !icon) {
return (
<SidebarRowLeadGlyph>
@ -39,7 +39,7 @@ export function projectIcon({ color, icon }: SidebarProjectTree) {
return (
<SidebarRowLeadGlyph style={color ? { color } : undefined}>
<Codicon name={icon || 'folder-library'} size={SIDEBAR_LEAD_ICON_SIZE} />
<Codicon name={icon || (isNoProject ? 'home' : 'folder-library')} size={SIDEBAR_LEAD_ICON_SIZE} />
</SidebarRowLeadGlyph>
)
}
@ -117,10 +117,12 @@ export function ProjectOverviewRow({
<SidebarRowShell
actions={
<>
{onNewSession && (
{/* Home has no folder to start a chat in the sidebar's own "New
session" is that button and no record to rename or delete. */}
{onNewSession && !project.isNoProject && (
<WorkspaceAddButton label={s.newSessionIn(project.label)} onClick={() => onNewSession(project.path)} />
)}
<ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />
{!project.isNoProject && <ProjectMenu anchorRef={rowRef} isActive={isActive} project={project} />}
</>
}
className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')}

View file

@ -8,6 +8,7 @@ import {
kanbanWorktreeDir,
liveSessionProjectId,
mergeRepoWorktreeGroups,
NO_PROJECT_ID,
overlayLiveLanes,
overlayLivePreviews,
sessionProjectColor,
@ -458,6 +459,25 @@ const projectNode = (over: Partial<SidebarProjectTree> & Pick<SidebarProjectTree
...over
})
// Home as the backend emits it: no path, one synthetic lane carrying the rows.
const homeNode = (sessions: SessionInfo[]): SidebarProjectTree =>
projectNode({
id: NO_PROJECT_ID,
isNoProject: true,
label: 'Home',
path: null,
repos: [
{
id: NO_PROJECT_ID,
label: 'Home',
path: null,
sessionCount: sessions.length,
groups: [lane({ id: NO_PROJECT_ID, label: 'Home', sessions })]
}
],
sessionCount: sessions.length
})
describe('liveSessionProjectId', () => {
it('maps a brand-new (unpersisted) session to its auto project (the repo root)', () => {
expect(liveSessionProjectId(makeSession('/www/app'), [])).toBe('/www/app')
@ -789,6 +809,26 @@ describe('overlayLiveLanes', () => {
expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['keep'])
expect(overlaid.sessionCount).toBe(1)
})
it('adds a brand-new detached chat to Home, and evicts a deleted one', () => {
const existing = makeSession(null, { id: 'old', started_at: 1 })
const doomed = makeSession(null, { id: 'gone', started_at: 2 })
const home = homeNode([existing, doomed])
const overlaid = overlayLiveLanes(home, [makeSession(null, { id: 'fresh', started_at: 9 })], new Set(['gone']))
expect(overlaid.repos[0].groups[0].sessions.map(s => s.id)).toEqual(['fresh', 'old'])
expect(overlaid.sessionCount).toBe(2)
})
it('leaves Home alone for a session that has a cwd', () => {
// A cwd-carrying row the backend hasn't placed yet (junk root, deleted
// workspace) needs its probes — guessing here would flicker it into Home
// and back out on the next snapshot.
const home = homeNode([])
expect(overlayLiveLanes(home, [makeSession('/www/app', { id: 'fresh' })])).toBe(home)
})
})
describe('overlayLivePreviews', () => {
@ -818,4 +858,10 @@ describe('overlayLivePreviews', () => {
expect(previews['/www/app'].map(s => s.id)).toEqual(['old'])
})
it('previews a detached session under Home, which no cwd could place', () => {
const previews = overlayLivePreviews([homeNode([])], [makeSession(null, { id: 'fresh' })], [], 3)
expect(previews[NO_PROJECT_ID].map(s => s.id)).toEqual(['fresh'])
})
})

View file

@ -56,7 +56,9 @@ export interface SidebarProjectTree {
// A git repo root promoted automatically (not a user-created projects.db row).
// Deletable = dismissable.
isAuto?: boolean
// The synthetic "No project" bucket for cwd-less sessions.
// The synthetic bucket (labeled "Home") holding every session no project
// claimed. It has no folder, so no repo/worktree structure — its one lane
// exists only to carry the rows.
isNoProject?: boolean
repos: SidebarWorkspaceTree[]
sessionCount: number
@ -113,6 +115,18 @@ export function kanbanWorktreeDir(path: string): null | string {
/** Label for a main-checkout lane whose session recorded no branch. */
export const DEFAULT_BRANCH_LABEL = 'main'
/** Id of the Home bucket (must match the backend tree's `NO_PROJECT_ID`). */
export const NO_PROJECT_ID = '__no_project__'
/**
* A session with nowhere to be placed: no cwd and no recorded repo root. These
* are the rows the Home bucket owns, and the only ones the live overlay can
* hand it a row WITH a cwd that the backend still couldn't place (junk root,
* deleted workspace) needs the backend's probes, so it waits for the snapshot.
*/
export const isDetachedSession = (session: SessionInfo): boolean =>
!(session.cwd || '').trim() && !(session.git_repo_root || '').trim()
/** The one definition of a main-checkout lane id (must match the backend tree). */
export const branchLaneId = (repoRoot: string, branch?: string): string =>
`${repoRoot}::branch::${(branch ?? '').trim()}`
@ -568,12 +582,44 @@ export function overlayRepoLanes(
return { ...repo, groups, sessionCount: groups.reduce((n, g) => n + g.sessions.length, 0) }
}
/**
* Home's overlay: its rows have no cwd to place, so this is a plain upsert of
* detached live sessions into its single lane a brand-new project-less chat
* shows the instant it's created, matching the flat Recents list.
*/
function overlayHomeLane(
project: SidebarProjectTree,
live: SessionInfo[],
removed: ReadonlySet<string>
): SidebarProjectTree {
const lane = project.repos[0]?.groups[0]
const detached = live.filter(session => isDetachedSession(session) && !removed.has(session.id))
const kept = (lane?.sessions ?? []).filter(session => !removed.has(session.id))
if (!detached.length && kept.length === (lane?.sessions.length ?? 0)) {
return project
}
const sessions = detached.reduce(upsertSession, kept)
const nextLane = { id: NO_PROJECT_ID, label: project.label, path: null, sessions }
return {
...project,
repos: [{ id: NO_PROJECT_ID, label: project.label, path: null, groups: [nextLane], sessionCount: sessions.length }],
sessionCount: sessions.length
}
}
/** Project-level overlay: {@link overlayRepoLanes} across every repo subtree. */
export function overlayLiveLanes(
project: SidebarProjectTree,
live: SessionInfo[],
removed: ReadonlySet<string> = NO_REMOVED
): SidebarProjectTree {
if (project.isNoProject) {
return overlayHomeLane(project, live, removed)
}
let changed = false
const repos = project.repos.map(repo => {
@ -591,7 +637,7 @@ export function overlayLiveLanes(
return { ...project, repos, sessionCount: repos.reduce((n, repo) => n + repo.sessionCount, 0) }
}
/** Merge live sessions into per-project overview previews, keyed by project path. */
/** Merge live sessions into per-project overview previews, keyed by project id. */
export function overlayLivePreviews(
projects: SidebarProjectTree[],
live: SessionInfo[],
@ -606,7 +652,8 @@ export function overlayLivePreviews(
continue
}
const projectId = liveSessionProjectId(session, explicitProjects)
const projectId =
liveSessionProjectId(session, explicitProjects) ?? (isDetachedSession(session) ? NO_PROJECT_ID : null)
if (!projectId) {
continue
@ -620,10 +667,6 @@ export function overlayLivePreviews(
const out: Record<string, SessionInfo[]> = {}
for (const node of projects) {
if (!node.path) {
continue
}
const liveRows = byProject.get(node.id) ?? []
const base = (node.previewSessions ?? []).filter(session => !removed.has(session.id))
@ -640,7 +683,7 @@ export function overlayLivePreviews(
}
}
out[node.path] = [...map.values()].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
out[node.id] = [...map.values()].sort((a, b) => sessionRecency(b) - sessionRecency(a)).slice(0, limit)
}
return out

View file

@ -109,7 +109,7 @@ interface SidebarSessionsSectionProps {
// which then passes `projectContent` on the next render. Takes precedence
// over `tree` / `groups`.
projectOverview?: SidebarProjectTree[]
// Per-project preview rows (from the backend tree), keyed by project path.
// Per-project preview rows (from the backend tree), keyed by project id.
projectOverviewPreviews?: Record<string, SessionInfo[]>
// True while the backend project tree is loading (overview skeleton).
projectsLoading?: boolean
@ -305,36 +305,45 @@ export function SidebarSessionsSection({
} else if (showEmptyState) {
inner = emptyState
} else if (projectOverview?.length) {
// The model is already ordered (default sort groups explicit-before-auto;
// a manual drag-order, when present, wins). Render in that order and make
// rows drag-to-reorder when a handler is wired.
const projectsDraggable = projectOverview.length > 1 && !!onReorderProjects
// The model is already ordered (Home leads; then the default sort groups
// explicit-before-auto, with a manual drag-order winning when present).
// Render in that order and make rows drag-to-reorder when a handler is
// wired — Home stays outside the sortable list, it's a fixture.
const home = projectOverview[0]?.isNoProject ? projectOverview[0] : undefined
const sortableProjects = home ? projectOverview.slice(1) : projectOverview
const projectsDraggable = sortableProjects.length > 1 && !!onReorderProjects
const Row = projectsDraggable ? SortableProjectOverviewRow : ProjectOverviewRow
const rows = projectOverview.map(project => (
<Row
const projectRow = (project: SidebarProjectTree, Component: typeof ProjectOverviewRow) => (
<Component
activeProjectId={activeProjectId}
key={project.id}
onEnter={onEnterProject}
onNewSession={onNewSessionInWorkspace}
previewSessions={project.path ? projectOverviewPreviews?.[project.path] : undefined}
previewSessions={projectOverviewPreviews?.[project.id]}
project={project}
renderRows={renderRows}
/>
))
)
inner =
projectsDraggable && onReorderProjects ? (
<ReorderableList
ids={projectOverview.map(project => project.id)}
onReorder={onReorderProjects}
sensors={dndSensors}
>
{rows}
</ReorderableList>
) : (
rows
)
const rows = sortableProjects.map(project => projectRow(project, Row))
inner = (
<>
{home && projectRow(home, ProjectOverviewRow)}
{projectsDraggable && onReorderProjects ? (
<ReorderableList
ids={sortableProjects.map(project => project.id)}
onReorder={onReorderProjects}
sensors={dndSensors}
>
{rows}
</ReorderableList>
) : (
rows
)}
</>
)
} else if (groups?.length) {
// Profile/source groups never reorder; render them flat with static rows.
inner = groups.map(group => (

View file

@ -1,9 +1,10 @@
import { atom } from 'nanostores'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
import { NO_PROJECT_ID, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
import { $sidebarAgentsGrouped } from '@/store/layout'
import { $activeGatewayProfile } from '@/store/profile'
import { applyConfiguredDefaultProjectDir } from '@/store/session'
import {
$activeProjectId,
@ -25,6 +26,7 @@ import {
refreshProjects,
refreshProjectTree,
refreshWorktrees,
resolveNewSessionCwd,
scanAndRecordRepos,
tombstoneSessions
} from './projects'
@ -99,9 +101,9 @@ describe('project scope', () => {
expect($projectScope.get()).toBe(ALL_PROJECTS)
})
it('entering the synthetic No-project bucket still scopes (no active pin)', () => {
enterProject('__no_project__')
expect($projectScope.get()).toBe('__no_project__')
it('entering the synthetic Home bucket still scopes (no active pin)', () => {
enterProject(NO_PROJECT_ID)
expect($projectScope.get()).toBe(NO_PROJECT_ID)
})
it('persists the scope to localStorage', () => {
@ -110,6 +112,30 @@ describe('project scope', () => {
})
})
describe('resolveNewSessionCwd', () => {
beforeEach(() => {
$projectScope.set(ALL_PROJECTS)
applyConfiguredDefaultProjectDir('/home/user/configured')
})
afterEach(() => {
applyConfiguredDefaultProjectDir(null)
$projectScope.set(ALL_PROJECTS)
})
it('starts a chat detached inside Home, ignoring the configured default dir', () => {
// Attaching the default dir here would move the new chat out of Home the
// moment it was created — "no folder" is what the bucket means.
enterProject(NO_PROJECT_ID)
expect(resolveNewSessionCwd()).toBe('')
})
it('still falls back to the configured default outside Home', () => {
expect(resolveNewSessionCwd()).toBe('/home/user/configured')
})
})
describe('projectNameForCwd', () => {
const treeNode = (
over: Partial<SidebarProjectTree> & Pick<SidebarProjectTree, 'id' | 'label'>

View file

@ -1,6 +1,10 @@
import { atom } from 'nanostores'
import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
import {
liveSessionProjectId,
NO_PROJECT_ID,
type SidebarProjectTree
} from '@/app/chat/sidebar/projects/workspace-groups'
import type { HermesGitBaseBranch, HermesGitBranch } from '@/global'
import { getHermesConfig, type HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
@ -146,8 +150,8 @@ export function enterProject(id: string): void {
$projectScope.set(id)
// Only explicit, persisted projects (ids are `p_<hex>`) become active. Auto
// projects (ids are filesystem paths) and the "No project" bucket have no
// durable row to pin, so they're view-scope only.
// projects (ids are filesystem paths) and the Home bucket have no durable row
// to pin, so they're view-scope only.
if (id.startsWith('p_')) {
void setActiveProject(id).catch(() => undefined)
}
@ -166,6 +170,12 @@ export function exitProjectScope(): void {
export function resolveNewSessionCwd(): string {
const scope = $projectScope.get()
// Inside Home, "no folder" is the point: a new chat must stay detached rather
// than silently attaching to the configured default dir and leaving Home.
if (scope === NO_PROJECT_ID) {
return ''
}
if (scope !== ALL_PROJECTS) {
const project = $projectTree.get().find(node => node.id === scope)
const cwd = (project?.path || project?.repos.find(repo => repo.path)?.path || '').trim()
@ -211,8 +221,8 @@ export function projectIdForCwd(cwd: string): null | string {
// match), or null when the cwd sits in no named project. The status bar reads
// this to label the workspace by project instead of the bare cwd leaf. We skip
// auto-projects (a repo root promoted with no projects.db row) and the synthetic
// "No project" bucket on purpose: those have no human name, so their sessions
// keep the cwd-leaf label — matching the backend `_project_info_for_cwd`, which
// Home bucket on purpose: those have no human name, so their sessions keep the
// cwd-leaf label — matching the backend `_project_info_for_cwd`, which
// only resolves projects.db rows, so the desktop and TUI name the same session
// identically without threading a second per-session copy through session.info.
export function projectNameForCwd(cwd: string): null | string {