mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge pull request #72900 from NousResearch/bb/desktop-home-project
Add a Home project at the top of the desktop sidebar
This commit is contained in:
commit
c63be0daf7
19 changed files with 403 additions and 84 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)')}
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
|
|
|
|||
|
|
@ -1546,11 +1546,11 @@ export const ar = defineLocale({
|
|||
allPinned: 'كل الجلسات مثبتة',
|
||||
shiftClickHint: 'استخدم Shift للتحديد المتعدد',
|
||||
noWorkspace: 'بدون مساحة عمل',
|
||||
noProject: 'لا يوجد مشروع',
|
||||
projectEmpty: 'لا توجد جلسات بعد',
|
||||
noSessions: 'لا توجد جلسات بعد',
|
||||
projects: {
|
||||
sectionLabel: 'المشاريع',
|
||||
home: 'الرئيسية',
|
||||
newButton: 'مشروع جديد',
|
||||
createTitle: 'مشروع جديد',
|
||||
createDesc: 'سمِّ مساحة العمل وأضف مجلدا أو أكثر.',
|
||||
|
|
|
|||
|
|
@ -1804,11 +1804,11 @@ export const en: Translations = {
|
|||
allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.',
|
||||
shiftClickHint: 'Shift-click a chat to pin',
|
||||
noWorkspace: 'No workspace',
|
||||
noProject: 'No project',
|
||||
projectEmpty: 'No sessions yet',
|
||||
noSessions: 'No sessions yet',
|
||||
projects: {
|
||||
sectionLabel: 'Projects',
|
||||
home: 'Home',
|
||||
newButton: 'New project',
|
||||
createTitle: 'New project',
|
||||
createDesc: 'Name a workspace and add one or more folders.',
|
||||
|
|
|
|||
|
|
@ -1668,11 +1668,11 @@ export const ja = defineLocale({
|
|||
allPinned: 'ここにあるものはすべてピン留めされています。チャットのピン留めを解除すると最近のものに表示されます。',
|
||||
shiftClickHint: 'Shift クリックでピン留め · ドラッグで並べ替え',
|
||||
noWorkspace: 'ワークスペースなし',
|
||||
noProject: 'プロジェクトなし',
|
||||
projectEmpty: 'セッションはまだありません',
|
||||
noSessions: 'セッションはまだありません',
|
||||
projects: {
|
||||
sectionLabel: 'プロジェクト',
|
||||
home: 'ホーム',
|
||||
newButton: '新規プロジェクト',
|
||||
createTitle: '新規プロジェクト',
|
||||
createDesc: 'ワークスペースに名前を付け、1つ以上のフォルダを追加します。',
|
||||
|
|
|
|||
|
|
@ -1508,11 +1508,11 @@ export interface Translations {
|
|||
allPinned: string
|
||||
shiftClickHint: string
|
||||
noWorkspace: string
|
||||
noProject: string
|
||||
projectEmpty: string
|
||||
noSessions: string
|
||||
projects: {
|
||||
sectionLabel: string
|
||||
home: string
|
||||
newButton: string
|
||||
createTitle: string
|
||||
createDesc: string
|
||||
|
|
|
|||
|
|
@ -1616,11 +1616,11 @@ export const zhHant = defineLocale({
|
|||
allPinned: '這裡的全部已釘選。取消釘選某個聊天即可在最近中顯示。',
|
||||
shiftClickHint: 'Shift + 點擊聊天以釘選 · 拖曳以重新排序',
|
||||
noWorkspace: '無工作區',
|
||||
noProject: '無專案',
|
||||
projectEmpty: '尚無工作階段',
|
||||
noSessions: '尚無工作階段',
|
||||
projects: {
|
||||
sectionLabel: '專案',
|
||||
home: '主頁',
|
||||
newButton: '新增專案',
|
||||
createTitle: '新增專案',
|
||||
createDesc: '為工作區命名並新增一個或多個資料夾。',
|
||||
|
|
|
|||
|
|
@ -1994,11 +1994,11 @@ export const zh: Translations = {
|
|||
allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。',
|
||||
shiftClickHint: 'Shift+ 单击对话以置顶 · 拖动以重新排序',
|
||||
noWorkspace: '无工作区',
|
||||
noProject: '无项目',
|
||||
projectEmpty: '暂无会话',
|
||||
noSessions: '暂无会话',
|
||||
projects: {
|
||||
sectionLabel: '项目',
|
||||
home: '主页',
|
||||
newButton: '新建项目',
|
||||
createTitle: '新建项目',
|
||||
createDesc: '为工作区命名并添加一个或多个文件夹。',
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,26 @@ def _lane_ids(project):
|
|||
return [g["id"] for repo in project["repos"] for g in repo["groups"]]
|
||||
|
||||
|
||||
def _home(tree):
|
||||
"""The Home bucket, or None when nothing was left unplaced."""
|
||||
return next((p for p in tree["projects"] if p["id"] == pt.NO_PROJECT_ID), None)
|
||||
|
||||
|
||||
def _home_session_ids(tree):
|
||||
home = _home(tree)
|
||||
return [s["id"] for s in _sessions_of(home)] if home else []
|
||||
|
||||
|
||||
def _sessions_of(project):
|
||||
return [s for repo in project["repos"] for g in repo["groups"] for s in g["sessions"]]
|
||||
|
||||
|
||||
def _real_project_ids(tree):
|
||||
"""Project ids excluding the Home bucket (which is always present when any
|
||||
session went unplaced, so asserting on it in every test would be noise)."""
|
||||
return [p["id"] for p in tree["projects"] if p["id"] != pt.NO_PROJECT_ID]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -306,12 +326,12 @@ def test_scoped_session_ids_is_union_of_placed_sessions():
|
|||
)
|
||||
owned = _session("/www/app", branch="main")
|
||||
auto = _session("/www/repo", branch="main")
|
||||
homeless = _session(None) # no cwd -> belongs to no project
|
||||
homeless = _session(None) # no cwd -> the Home bucket
|
||||
|
||||
tree = pt.build_tree([project], [owned, auto, homeless], [], resolve, hydrate=True)
|
||||
|
||||
assert set(tree["scoped_session_ids"]) == {owned["id"], auto["id"]}
|
||||
assert homeless["id"] not in tree["scoped_session_ids"]
|
||||
assert set(tree["scoped_session_ids"]) == {owned["id"], auto["id"], homeless["id"]}
|
||||
assert _home_session_ids(tree) == [homeless["id"]]
|
||||
|
||||
|
||||
def test_overview_drops_session_rows_but_keeps_counts_and_previews():
|
||||
|
|
@ -403,8 +423,8 @@ def test_nested_project_folders_pick_the_deepest_match():
|
|||
|
||||
def test_junk_root_never_becomes_an_auto_project():
|
||||
# A session whose git root is HERMES_HOME (config/state) must not spawn a
|
||||
# phantom project; it falls through to flat Recents (unscoped). A real repo
|
||||
# alongside it still groups normally.
|
||||
# phantom project; it lands in the Home bucket. A real repo alongside it
|
||||
# still groups normally.
|
||||
resolve = _resolver(
|
||||
{
|
||||
"/home/me/.hermes": ("/home/me/.hermes", "/home/me/.hermes"),
|
||||
|
|
@ -417,9 +437,8 @@ def test_junk_root_never_becomes_an_auto_project():
|
|||
|
||||
tree = pt.build_tree([], [junk, real], [], resolve, hydrate=True, is_junk_root=is_junk)
|
||||
|
||||
ids = {p["id"] for p in tree["projects"]}
|
||||
assert ids == {"/www/app"}
|
||||
assert junk["id"] not in tree["scoped_session_ids"]
|
||||
assert _real_project_ids(tree) == ["/www/app"]
|
||||
assert _home_session_ids(tree) == [junk["id"]]
|
||||
assert real["id"] in tree["scoped_session_ids"]
|
||||
|
||||
|
||||
|
|
@ -462,8 +481,8 @@ def test_broad_default_non_git_cwd_stays_unscoped():
|
|||
is_junk_cwd=lambda path: path in {"/home/test", "/home/test/.hermes"},
|
||||
)
|
||||
|
||||
assert tree["projects"] == []
|
||||
assert detached["id"] not in tree["scoped_session_ids"]
|
||||
assert _real_project_ids(tree) == []
|
||||
assert _home_session_ids(tree) == [detached["id"]]
|
||||
|
||||
|
||||
def test_deleted_sibling_worktree_folds_into_parent_home_checkout():
|
||||
|
|
@ -509,18 +528,19 @@ def test_deleted_unrelated_workspace_does_not_become_a_project():
|
|||
# A deleted dir the sibling probe can't reach by name (`hermes-salvage-drafts`
|
||||
# shares no prefix with `hermes-agent`; `/tmp/scratch` was never a worktree)
|
||||
# must not be promoted to a phantom project — it can never be opened and can
|
||||
# only be dismissed by hand. The session stays in flat Recents.
|
||||
# only be dismissed by hand. Those sessions land in the Home bucket.
|
||||
resolve = _resolver({"/www/hermes-agent": ("/www/hermes-agent", "/www/hermes-agent")})
|
||||
sessions = [
|
||||
live, salvage, scratch = (
|
||||
_session("/www/hermes-agent", branch="main"),
|
||||
_session("/www/hermes-salvage-drafts/apps/desktop"),
|
||||
_session("/tmp/scratch"),
|
||||
]
|
||||
)
|
||||
on_disk = {"/www/hermes-agent"}
|
||||
|
||||
tree = pt.build_tree([], sessions, [], resolve, hydrate=True, exists=lambda p: p in on_disk)
|
||||
tree = pt.build_tree([], [live, salvage, scratch], [], resolve, hydrate=True, exists=lambda p: p in on_disk)
|
||||
|
||||
assert [p["id"] for p in tree["projects"]] == ["/www/hermes-agent"]
|
||||
assert _real_project_ids(tree) == ["/www/hermes-agent"]
|
||||
assert set(_home_session_ids(tree)) == {salvage["id"], scratch["id"]}
|
||||
|
||||
|
||||
def test_existing_non_git_workspace_still_becomes_a_project():
|
||||
|
|
@ -536,11 +556,12 @@ def test_existing_non_git_workspace_still_becomes_a_project():
|
|||
def test_stale_persisted_repo_root_does_not_become_a_project():
|
||||
# A session carrying a git_repo_root whose repo has since been deleted must
|
||||
# not resurrect it as a project on the strength of the persisted value alone.
|
||||
sessions = [_session("/tmp/gone/sub", repo_root="/tmp/gone")]
|
||||
stale = _session("/tmp/gone/sub", repo_root="/tmp/gone")
|
||||
|
||||
tree = pt.build_tree([], sessions, [], lambda _cwd: None, hydrate=True, exists=lambda _p: False)
|
||||
tree = pt.build_tree([], [stale], [], lambda _cwd: None, hydrate=True, exists=lambda _p: False)
|
||||
|
||||
assert tree["projects"] == []
|
||||
assert _real_project_ids(tree) == []
|
||||
assert _home_session_ids(tree) == [stale["id"]]
|
||||
|
||||
|
||||
def test_exists_defaults_to_keeping_everything():
|
||||
|
|
@ -566,6 +587,55 @@ def test_sibling_probe_is_bounded():
|
|||
assert len(probed) <= pt._MAX_SIBLING_PROBES
|
||||
|
||||
|
||||
def test_home_bucket_leads_the_tree_and_is_lossless():
|
||||
# Every session a project didn't claim belongs to Home, and Home leads the
|
||||
# list — so the grouped view shows the same set of sessions as flat Recents.
|
||||
resolve = _resolver({"/www/app": ("/www/app", "/www/app"), "/home/me": ("/home/me", "/home/me")})
|
||||
owned = _session("/www/app", branch="main")
|
||||
cwdless = _session(None)
|
||||
junked = _session("/home/me", branch="main")
|
||||
|
||||
tree = pt.build_tree(
|
||||
[],
|
||||
[owned, cwdless, junked],
|
||||
[],
|
||||
resolve,
|
||||
hydrate=True,
|
||||
is_junk_root=lambda root: root == "/home/me",
|
||||
)
|
||||
|
||||
assert tree["projects"][0]["id"] == pt.NO_PROJECT_ID
|
||||
assert set(_home_session_ids(tree)) == {cwdless["id"], junked["id"]}
|
||||
assert {s["id"] for p in tree["projects"] for s in _sessions_of(p)} == {
|
||||
owned["id"],
|
||||
cwdless["id"],
|
||||
junked["id"],
|
||||
}
|
||||
|
||||
|
||||
def test_home_bucket_is_absent_when_every_session_is_placed():
|
||||
# No leftovers, no Home row — a fully-organized sidebar shows only projects.
|
||||
resolve = _resolver({"/www/app": ("/www/app", "/www/app")})
|
||||
|
||||
tree = pt.build_tree([], [_session("/www/app", branch="main")], [], resolve, hydrate=True)
|
||||
|
||||
assert _home(tree) is None
|
||||
|
||||
|
||||
def test_home_bucket_carries_previews_and_drops_rows_in_overview_mode():
|
||||
sessions = [_session(None, started_at=t, last_active=t) for t in (10, 30, 20, 40)]
|
||||
|
||||
tree = pt.build_tree([], sessions, [], resolve=None, preview_limit=3, hydrate=False)
|
||||
home = _home(tree)
|
||||
|
||||
assert home["sessionCount"] == 4
|
||||
assert home["lastActive"] == 40
|
||||
assert [s["last_active"] for s in home["previewSessions"]] == [40, 30, 20]
|
||||
assert _sessions_of(home) == []
|
||||
assert home["isNoProject"] is True
|
||||
assert home["path"] is None
|
||||
|
||||
|
||||
def test_colliding_repo_basenames_disambiguate_labels():
|
||||
resolve = _resolver(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ all key off these exact strings:
|
|||
|
||||
- explicit project id .......... ``p_<hex>`` (from projects.db)
|
||||
- auto/discovered project id ... the repo root path
|
||||
- home (no-project) bucket ..... ``__no_project__``
|
||||
- repo node id ................. the repo root path
|
||||
- main branch lane id .......... ``<repoRoot>::branch::<branch>`` (or ``::branch::``)
|
||||
- kanban bucket lane id ........ ``<repoRoot>::kanban``
|
||||
|
|
@ -48,6 +49,14 @@ _KANBAN_DIR_RE = re.compile(r"^(.*[/\\]\.worktrees)[/\\]t_[0-9a-f]+[/\\]?$")
|
|||
_TRUNK_BRANCHES = {"main", "master", "trunk", "develop"}
|
||||
DEFAULT_BRANCH_LABEL = "main"
|
||||
|
||||
# The synthetic bucket holding every session no project claimed — a chat with no
|
||||
# cwd at all, or one whose folder can't be promoted (the bare home dir, HERMES
|
||||
# state, a workspace that has since been deleted). Without it those sessions are
|
||||
# invisible in the grouped view. The desktop labels it "Home"; the id/flag stay
|
||||
# named for what the bucket MEANS, since that's what membership keys off.
|
||||
NO_PROJECT_ID = "__no_project__"
|
||||
NO_PROJECT_LABEL = "Home"
|
||||
|
||||
# How many sibling candidates to try when recovering a deleted worktree's parent
|
||||
# repo (see ``_probe_sibling_worktree``). Each miss costs a git probe, so keep it
|
||||
# tight — real suffixes are one or two segments.
|
||||
|
|
@ -508,6 +517,7 @@ def _project_node(
|
|||
color: Any = None,
|
||||
icon: Any = None,
|
||||
is_auto: bool = False,
|
||||
is_no_project: bool = False,
|
||||
) -> dict:
|
||||
return {
|
||||
"id": pid,
|
||||
|
|
@ -516,6 +526,7 @@ def _project_node(
|
|||
"color": color,
|
||||
"icon": icon,
|
||||
"isAuto": is_auto,
|
||||
"isNoProject": is_no_project,
|
||||
"sessionCount": session_count,
|
||||
"lastActive": last_active,
|
||||
"repos": repos,
|
||||
|
|
@ -609,10 +620,13 @@ def build_tree(
|
|||
# The pre-Projects desktop grouped every non-empty cwd; keeping that fallback
|
||||
# prevents upgrades from flattening those sessions into Recents.
|
||||
by_auto_root: dict[str, dict] = {}
|
||||
# Every session no tier could place. These are the Home bucket's rows.
|
||||
homeless: list[dict] = []
|
||||
|
||||
def _add_auto(root: str, session: dict) -> None:
|
||||
key = _path_key(root)
|
||||
if not key:
|
||||
homeless.append(session)
|
||||
return
|
||||
bucket = by_auto_root.setdefault(key, {"root": root, "sessions": []})
|
||||
bucket["sessions"].append(session)
|
||||
|
|
@ -626,10 +640,13 @@ def build_tree(
|
|||
# session ran) and must not resurrect as a project.
|
||||
if not _junk(root) and _exists(root):
|
||||
_add_auto(root, session)
|
||||
else:
|
||||
homeless.append(session)
|
||||
continue
|
||||
|
||||
cwd = (session.get("cwd") or "").strip()
|
||||
if not cwd or _junk_cwd(cwd):
|
||||
homeless.append(session)
|
||||
continue
|
||||
placement = _place(
|
||||
cwd,
|
||||
|
|
@ -642,9 +659,11 @@ def build_tree(
|
|||
# also gone from disk (a deleted worktree whose name shares no prefix
|
||||
# with its parent, a removed /tmp scratch dir), promoting it mints a
|
||||
# phantom project that can never be opened and can only be dismissed by
|
||||
# hand. The session keeps its place in flat Recents.
|
||||
# hand. The session goes to Home instead.
|
||||
if placement and _exists(placement["repo_key"]):
|
||||
_add_auto(placement["repo_key"], session)
|
||||
else:
|
||||
homeless.append(session)
|
||||
|
||||
seen: set[str] = set()
|
||||
for bucket in by_auto_root.values():
|
||||
|
|
@ -661,6 +680,7 @@ def build_tree(
|
|||
None,
|
||||
)
|
||||
if repo_node is None:
|
||||
homeless.extend(auto_sessions)
|
||||
continue
|
||||
seen.add(auto_key)
|
||||
scoped_ids.extend(s["id"] for s in auto_sessions if s.get("id"))
|
||||
|
|
@ -708,4 +728,41 @@ def build_tree(
|
|||
# Explicit projects keep their user-chosen names untouched.
|
||||
_disambiguate_labels([p for p in result if p.get("isAuto")])
|
||||
|
||||
# Tier 0: everything the tiers above could not place, so the grouped view
|
||||
# loses no session. It has no folder, hence no repo/lane structure — the one
|
||||
# synthetic lane exists purely to carry the rows in the tree's shape. Leads
|
||||
# the list; omitted entirely when empty, so a project-less install is blank.
|
||||
if homeless:
|
||||
homeless.sort(key=_session_time, reverse=True)
|
||||
scoped_ids.extend(s["id"] for s in homeless if s.get("id"))
|
||||
lane = {
|
||||
"id": NO_PROJECT_ID,
|
||||
"label": NO_PROJECT_LABEL,
|
||||
"path": None,
|
||||
"isMain": False,
|
||||
"isKanban": False,
|
||||
"sessions": homeless if hydrate else [],
|
||||
}
|
||||
result.insert(
|
||||
0,
|
||||
_project_node(
|
||||
pid=NO_PROJECT_ID,
|
||||
label=NO_PROJECT_LABEL,
|
||||
path=None,
|
||||
repos=[
|
||||
{
|
||||
"id": NO_PROJECT_ID,
|
||||
"label": NO_PROJECT_LABEL,
|
||||
"path": None,
|
||||
"groups": [lane],
|
||||
"sessionCount": len(homeless),
|
||||
}
|
||||
],
|
||||
session_count=len(homeless),
|
||||
last_active=_last_active(homeless),
|
||||
preview_sessions=_previews(homeless),
|
||||
is_no_project=True,
|
||||
),
|
||||
)
|
||||
|
||||
return {"projects": result, "scoped_session_ids": scoped_ids}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue