mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
Group recents as parent-repo → worktree → sessions using local git metadata (probed over IPC, with a path-name heuristic fallback for remote backends). Single-worktree repos collapse to one level. Sessions order by creation time and never reshuffle on new messages. Also: fuse the status stack to the composer border, restore icon actions in the queue panel, fix sidebar label truncation and drag styling, hide sticky-message attachments while pinned, and bump the terminal font.
149 lines
4.9 KiB
TypeScript
149 lines
4.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
|
||
import type { HermesWorktreeInfo } from '@/global'
|
||
import type { SessionInfo } from '@/types/hermes'
|
||
|
||
import { uniqueCwds, workspaceGroupsFor, workspaceTreeFor, type WorktreeResolver } from './workspace-groups'
|
||
|
||
let nextId = 0
|
||
|
||
function makeSession(cwd: null | string, overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||
return {
|
||
archived: false,
|
||
cwd,
|
||
ended_at: null,
|
||
id: `s${nextId++}`,
|
||
input_tokens: 0,
|
||
is_active: false,
|
||
last_active: 1_000,
|
||
message_count: 1,
|
||
model: 'claude',
|
||
output_tokens: 0,
|
||
preview: null,
|
||
source: 'cli',
|
||
started_at: 1_000,
|
||
title: null,
|
||
tool_call_count: 0,
|
||
...overrides
|
||
}
|
||
}
|
||
|
||
const labels = (sessions: SessionInfo[]) => workspaceGroupsFor(sessions, 'No workspace').map(g => g.label)
|
||
|
||
describe('workspaceGroupsFor', () => {
|
||
it('groups by full cwd, not by basename — same-named folders are separate groups', () => {
|
||
const groups = workspaceGroupsFor(
|
||
[makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')],
|
||
'No workspace'
|
||
)
|
||
|
||
expect(groups).toHaveLength(2)
|
||
})
|
||
|
||
it('disambiguates colliding basenames by walking up the path', () => {
|
||
expect(
|
||
labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/a/hermes-agent-wt-rtl/apps/desktop')])
|
||
).toEqual(['hermes-agent/apps/desktop', 'hermes-agent-wt-rtl/apps/desktop'])
|
||
})
|
||
|
||
it('leaves a unique basename as its short label', () => {
|
||
expect(labels([makeSession('/a/hermes-agent/apps/desktop'), makeSession('/b/heval-py')])).toEqual([
|
||
'desktop',
|
||
'heval-py'
|
||
])
|
||
})
|
||
|
||
it('grows the prefix past one segment when the parent also collides', () => {
|
||
expect(labels([makeSession('/x/proj/apps/desktop'), makeSession('/y/proj/apps/desktop')])).toEqual([
|
||
'x/proj/apps/desktop',
|
||
'y/proj/apps/desktop'
|
||
])
|
||
})
|
||
|
||
it('keeps the synthetic no-workspace group untouched even if a real group shares its label', () => {
|
||
const groups = workspaceGroupsFor([makeSession(null), makeSession('/a/No workspace')], 'No workspace')
|
||
const noWorkspace = groups.find(g => g.path === null)
|
||
|
||
expect(noWorkspace?.label).toBe('No workspace')
|
||
})
|
||
})
|
||
|
||
const info = (over: Partial<HermesWorktreeInfo> & Pick<HermesWorktreeInfo, 'repoRoot' | 'worktreeRoot'>): HermesWorktreeInfo => ({
|
||
branch: null,
|
||
isMainWorktree: false,
|
||
...over
|
||
})
|
||
|
||
describe('workspaceTreeFor', () => {
|
||
it('heuristic nests `<repo>-wt-<branch>` under its sibling repo', () => {
|
||
const tree = workspaceTreeFor(
|
||
[makeSession('/www/hermes-agent'), makeSession('/www/hermes-agent-wt-rtl')],
|
||
'No workspace'
|
||
)
|
||
|
||
expect(tree).toHaveLength(1)
|
||
expect(tree[0].label).toBe('hermes-agent')
|
||
expect(tree[0].groups.map(g => g.label).sort()).toEqual(['hermes-agent', 'rtl'])
|
||
})
|
||
|
||
it('git metadata is authoritative — worktrees group by repoRoot regardless of directory naming', () => {
|
||
const resolver: WorktreeResolver = cwd => {
|
||
if (cwd === '/www/hermes-agent') {
|
||
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/www/hermes-agent', isMainWorktree: true, branch: 'main' })
|
||
}
|
||
|
||
if (cwd === '/elsewhere/ha-rtl') {
|
||
return info({ repoRoot: '/www/hermes-agent', worktreeRoot: '/elsewhere/ha-rtl', branch: 'rtl' })
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
const tree = workspaceTreeFor(
|
||
[makeSession('/www/hermes-agent'), makeSession('/elsewhere/ha-rtl')],
|
||
'No workspace',
|
||
resolver
|
||
)
|
||
|
||
expect(tree).toHaveLength(1)
|
||
expect(tree[0].label).toBe('hermes-agent')
|
||
// The main checkout labels by directory (its branch is transient — using it
|
||
// would misattribute old sessions to the currently checked-out branch);
|
||
// linked worktrees label by branch.
|
||
expect(tree[0].groups.map(g => g.label)).toEqual(['hermes-agent', 'rtl'])
|
||
})
|
||
|
||
it('a standalone directory is its own parent (always parent → worktree → sessions)', () => {
|
||
const tree = workspaceTreeFor([makeSession('/www/heval-node')], 'No workspace')
|
||
|
||
expect(tree).toHaveLength(1)
|
||
expect(tree[0].label).toBe('heval-node')
|
||
expect(tree[0].groups).toHaveLength(1)
|
||
expect(tree[0].groups[0].label).toBe('heval-node')
|
||
})
|
||
|
||
it('aggregates session counts across a repo’s worktrees', () => {
|
||
const tree = workspaceTreeFor(
|
||
[makeSession('/www/ha'), makeSession('/www/ha-wt-x'), makeSession('/www/ha-wt-x')],
|
||
'No workspace'
|
||
)
|
||
|
||
const parent = tree.find(p => p.label === 'ha')
|
||
|
||
expect(parent?.sessionCount).toBe(3)
|
||
})
|
||
|
||
it('no-workspace sessions form their own parent', () => {
|
||
const tree = workspaceTreeFor([makeSession(null)], 'No workspace')
|
||
|
||
expect(tree).toHaveLength(1)
|
||
expect(tree[0].label).toBe('No workspace')
|
||
expect(tree[0].path).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('uniqueCwds', () => {
|
||
it('dedupes and drops empty/whitespace cwds', () => {
|
||
expect(uniqueCwds([makeSession('/a'), makeSession('/a'), makeSession(null), makeSession(' ')])).toEqual(['/a'])
|
||
})
|
||
})
|