mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
Merge pull request #67282 from NousResearch/bb/unify-project-status
feat: unify active-project identity in chat status (supersedes #64721)
This commit is contained in:
commit
f099b469de
9 changed files with 281 additions and 6 deletions
|
|
@ -16,6 +16,7 @@ import { cn } from '@/lib/utils'
|
|||
import { copyFilePath, revealFile } from '@/store/file-actions'
|
||||
import { revealFileInTree } from '@/store/layout'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $projectTree, projectNameForCwd } from '@/store/projects'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
|
|
@ -91,6 +92,12 @@ export function useStatusbarItems({
|
|||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const primaryBusy = useStore($busy)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
// Derive the workspace's project name from the already-cached project tree
|
||||
// (backend truth via projects.*), so the status item labels by project without
|
||||
// a second per-session copy of the same fact. Re-derives whenever the cwd or
|
||||
// the tree changes; null (no named project) falls back to the cwd leaf below.
|
||||
const projectTree = useStore($projectTree)
|
||||
const projectName = useMemo(() => projectNameForCwd(currentCwd), [currentCwd, projectTree])
|
||||
const primaryUsage = useStore($currentUsage)
|
||||
const gatewayRestarting = useStore($gatewayRestarting)
|
||||
const primarySessionStartedAt = useStore($sessionStartedAt)
|
||||
|
|
@ -313,7 +320,10 @@ export function useStatusbarItems({
|
|||
hidden: !currentCwd,
|
||||
icon: <FolderOpen className="size-3" />,
|
||||
id: 'workspace-cwd',
|
||||
label: currentCwd ? workspaceLabel(currentCwd) : undefined,
|
||||
// Prefer the named project; fall back to the cwd leaf. The full cwd is
|
||||
// always in the tooltip (`title` below), so hovering reveals where the
|
||||
// session actually sits — the worktree/subfolder, not just the project.
|
||||
label: projectName || (currentCwd ? workspaceLabel(currentCwd) : undefined),
|
||||
menuItems: currentCwd
|
||||
? [
|
||||
{
|
||||
|
|
@ -388,6 +398,7 @@ export function useStatusbarItems({
|
|||
inferenceReady,
|
||||
inferenceStatus?.reason,
|
||||
openAgents,
|
||||
projectName,
|
||||
subagentsFailed,
|
||||
subagentsRunning,
|
||||
toggleCommandCenter
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
|
||||
import { $sidebarAgentsGrouped } from '@/store/layout'
|
||||
|
||||
import type { SidebarProjectTree } from '@/app/chat/sidebar/projects/workspace-groups'
|
||||
|
||||
import {
|
||||
$activeProjectId,
|
||||
$projectScope,
|
||||
$projectsRpcAvailable,
|
||||
$projectTree,
|
||||
$worktreeRefreshToken,
|
||||
ALL_PROJECTS,
|
||||
createProject,
|
||||
|
|
@ -13,6 +16,7 @@ import {
|
|||
exitProjectScope,
|
||||
openProjectCreate,
|
||||
pickProjectFolder,
|
||||
projectNameForCwd,
|
||||
refreshProjects,
|
||||
refreshWorktrees
|
||||
} from './projects'
|
||||
|
|
@ -81,6 +85,66 @@ describe('project scope', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('projectNameForCwd', () => {
|
||||
const treeNode = (over: Partial<SidebarProjectTree> & Pick<SidebarProjectTree, 'id' | 'label'>): SidebarProjectTree => ({
|
||||
path: null,
|
||||
repos: [],
|
||||
sessionCount: 0,
|
||||
...over
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
$projectTree.set([])
|
||||
})
|
||||
|
||||
it('names the explicit project owning the cwd (longest path match)', () => {
|
||||
$projectTree.set([
|
||||
treeNode({ id: 'p_web', label: 'Website', path: '/repos/website' }),
|
||||
treeNode({ id: 'p_api', label: 'API', path: '/repos/api' })
|
||||
])
|
||||
|
||||
expect(projectNameForCwd('/repos/website/src/app')).toBe('Website')
|
||||
})
|
||||
|
||||
it('matches nested repo and worktree paths, not just the project root', () => {
|
||||
$projectTree.set([
|
||||
treeNode({
|
||||
id: 'p_mono',
|
||||
label: 'Monorepo',
|
||||
path: '/repos/mono',
|
||||
repos: [
|
||||
{
|
||||
id: 'r1',
|
||||
label: 'mono',
|
||||
path: '/repos/mono',
|
||||
sessionCount: 0,
|
||||
groups: [{ id: 'g1', label: 'feature', path: '/elsewhere/mono-feature', sessions: [] }]
|
||||
}
|
||||
]
|
||||
})
|
||||
])
|
||||
|
||||
// A linked worktree lives OUTSIDE the project root but still belongs to it.
|
||||
expect(projectNameForCwd('/elsewhere/mono-feature/src')).toBe('Monorepo')
|
||||
})
|
||||
|
||||
it('ignores auto-projects and the No-project bucket (no named identity)', () => {
|
||||
$projectTree.set([
|
||||
treeNode({ id: '/repos/loose', label: 'loose', path: '/repos/loose', isAuto: true }),
|
||||
treeNode({ id: '__no_project__', label: 'No project', path: null, isNoProject: true })
|
||||
])
|
||||
|
||||
expect(projectNameForCwd('/repos/loose/src')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a cwd in no project and for a blank cwd', () => {
|
||||
$projectTree.set([treeNode({ id: 'p_web', label: 'Website', path: '/repos/website' })])
|
||||
|
||||
expect(projectNameForCwd('/somewhere/else')).toBeNull()
|
||||
expect(projectNameForCwd('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('worktree refresh', () => {
|
||||
it('refreshWorktrees bumps the probe token so useRepoWorktreeMap refetches', () => {
|
||||
const before = $worktreeRefreshToken.get()
|
||||
|
|
|
|||
|
|
@ -180,6 +180,44 @@ export function projectIdForCwd(cwd: string): null | string {
|
|||
return best
|
||||
}
|
||||
|
||||
// The display NAME of the explicit, named project owning `cwd` (longest path
|
||||
// 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
|
||||
// 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 {
|
||||
const target = (cwd || '').trim()
|
||||
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
|
||||
let best: null | string = null
|
||||
let bestLen = -1
|
||||
|
||||
for (const project of $projectTree.get()) {
|
||||
if (project.isAuto || project.isNoProject) {
|
||||
continue
|
||||
}
|
||||
|
||||
const paths = [project.path, ...project.repos.flatMap(repo => [repo.path, ...repo.groups.map(group => group.path)])]
|
||||
|
||||
for (const path of paths) {
|
||||
const p = (path || '').trim()
|
||||
|
||||
if (p && underPath(p, target) && p.length > bestLen) {
|
||||
bestLen = p.length
|
||||
best = project.label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
// The active session's agent relocated itself (created/entered another repo or
|
||||
// worktree via the terminal — backend re-anchors its cwd and emits session.info).
|
||||
// Re-pull projects + tree so a freshly created/auto project and the relocated
|
||||
|
|
|
|||
|
|
@ -172,6 +172,55 @@ def test_add_folder_and_for_cwd(tmp_path):
|
|||
assert "branch" in resolved
|
||||
|
||||
|
||||
def test_project_info_for_cwd_returns_status_payload(tmp_path):
|
||||
# The status-surface resolver returns the owning project's identity for a
|
||||
# nested cwd — the shape the TUI status label + /status read.
|
||||
folder = tmp_path / "repo"
|
||||
folder.mkdir()
|
||||
created = _call("projects.create", {"name": "Repo", "folders": [str(folder)]})["project"]
|
||||
|
||||
nested = folder / "src"
|
||||
nested.mkdir()
|
||||
|
||||
assert server._project_info_for_cwd(str(nested)) == {
|
||||
"id": created["id"],
|
||||
"slug": "repo",
|
||||
"name": "Repo",
|
||||
"primary_path": str(folder),
|
||||
}
|
||||
|
||||
|
||||
def test_project_info_for_cwd_unowned_and_blank_are_none(tmp_path):
|
||||
# A cwd outside every project — and an empty cwd — carry no project, so the
|
||||
# status label falls back to the cwd leaf on every surface.
|
||||
owned = tmp_path / "owned"
|
||||
owned.mkdir()
|
||||
_call("projects.create", {"name": "Owned", "folders": [str(owned)]})
|
||||
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
|
||||
assert server._project_info_for_cwd(str(outside)) is None
|
||||
assert server._project_info_for_cwd("") is None
|
||||
|
||||
|
||||
def test_session_info_carries_project_for_owned_cwd(tmp_path):
|
||||
# session.info threads the resolved project through so the desktop/TUI can
|
||||
# name the workspace without a second round-trip.
|
||||
folder = tmp_path / "proj"
|
||||
folder.mkdir()
|
||||
_call("projects.create", {"name": "Proj", "folders": [str(folder)]})
|
||||
|
||||
info = server._session_info(None, {"cwd": str(folder), "session_key": "s1"})
|
||||
assert info["project"] == {
|
||||
"id": info["project"]["id"],
|
||||
"slug": "proj",
|
||||
"name": "Proj",
|
||||
"primary_path": str(folder),
|
||||
}
|
||||
assert info["project"]["name"] == "Proj"
|
||||
|
||||
|
||||
def test_update_and_archive(tmp_path):
|
||||
pid = _call("projects.create", {"name": "Orig", "folders": [str(tmp_path)]})["project"]["id"]
|
||||
|
||||
|
|
|
|||
|
|
@ -3699,6 +3699,35 @@ def _session_usage_snapshot(session: dict | None) -> dict:
|
|||
return dict(mirror_usage) if isinstance(mirror_usage, dict) else {}
|
||||
|
||||
|
||||
def _project_info_for_cwd(cwd: str) -> dict | None:
|
||||
"""Return the first-class Project owning ``cwd`` for UI status surfaces.
|
||||
|
||||
Backed by the per-profile projects.db (the same store the desktop's project
|
||||
tree caches), so the TUI status label, the desktop status bar, and ``/status``
|
||||
all name the session's workspace identically. Only explicit, named projects
|
||||
resolve here — an auto-discovered repo root has no projects.db row, so it
|
||||
falls back to the cwd leaf on every surface.
|
||||
"""
|
||||
if not str(cwd or "").strip():
|
||||
return None
|
||||
try:
|
||||
from hermes_cli import projects_db as pdb
|
||||
|
||||
with pdb.connect_closing() as conn:
|
||||
project = pdb.project_for_path(conn, cwd)
|
||||
if project is None:
|
||||
return None
|
||||
return {
|
||||
"id": project.id,
|
||||
"slug": project.slug,
|
||||
"name": project.name,
|
||||
"primary_path": project.primary_path,
|
||||
}
|
||||
except Exception:
|
||||
logger.debug("failed to resolve project for cwd", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _session_info(agent, session: dict | None = None) -> dict:
|
||||
if session is None:
|
||||
for candidate in _sessions.values():
|
||||
|
|
@ -3754,6 +3783,7 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
|||
"skills": dict(mirror.get("skills") or {}) if isinstance(mirror.get("skills"), dict) else {},
|
||||
"cwd": cwd,
|
||||
"branch": _git_branch_for_cwd(cwd),
|
||||
"project": _project_info_for_cwd(cwd),
|
||||
"personality": str(personality or ""),
|
||||
"running": bool((session or {}).get("running")),
|
||||
"title": _session_live_title(session or {}, session_key) if session_key else "",
|
||||
|
|
@ -4357,7 +4387,12 @@ def _apply_project_workspace(task_id: str, path: str, _name: str = "") -> None:
|
|||
info = (
|
||||
_session_info(agent, session)
|
||||
if agent is not None
|
||||
else {"cwd": resolved, "branch": _git_branch_for_cwd(resolved), "lazy": True}
|
||||
else {
|
||||
"cwd": resolved,
|
||||
"branch": _git_branch_for_cwd(resolved),
|
||||
"project": _project_info_for_cwd(resolved),
|
||||
"lazy": True,
|
||||
}
|
||||
)
|
||||
_emit("session.info", sid, info)
|
||||
except Exception:
|
||||
|
|
@ -5812,6 +5847,7 @@ def _(rid, params: dict) -> dict:
|
|||
"skills": {},
|
||||
"cwd": _sessions[sid]["cwd"],
|
||||
"branch": _git_branch_for_cwd(_sessions[sid]["cwd"]),
|
||||
"project": _project_info_for_cwd(_sessions[sid]["cwd"]),
|
||||
"lazy": True,
|
||||
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
|
||||
"profile_name": _current_profile_name(),
|
||||
|
|
@ -5958,6 +5994,7 @@ def _lazy_resume_info(cwd: str, *, model: str = "", provider: str = "") -> dict:
|
|||
info = {
|
||||
"cwd": cwd,
|
||||
"branch": _git_branch_for_cwd(cwd),
|
||||
"project": _project_info_for_cwd(cwd),
|
||||
"model": model or _resolve_model(),
|
||||
"tools": {},
|
||||
"skills": {},
|
||||
|
|
@ -6452,6 +6489,7 @@ def _(rid, params: dict) -> dict:
|
|||
info = _session_info(agent, session) if agent is not None else {
|
||||
"cwd": cwd,
|
||||
"branch": _git_branch_for_cwd(cwd),
|
||||
"project": _project_info_for_cwd(cwd),
|
||||
"lazy": True,
|
||||
}
|
||||
_emit("session.info", params.get("session_id", ""), info)
|
||||
|
|
@ -6551,8 +6589,10 @@ def _fallback_session_info(session: dict) -> dict:
|
|||
agent = session.get("agent")
|
||||
if agent is not None:
|
||||
return _session_info(agent)
|
||||
cwd = _default_session_cwd()
|
||||
return {
|
||||
"cwd": _default_session_cwd(),
|
||||
"cwd": cwd,
|
||||
"project": _project_info_for_cwd(cwd),
|
||||
"lazy": True,
|
||||
"model": _resolve_model(),
|
||||
"skills": {},
|
||||
|
|
@ -8618,12 +8658,15 @@ def _(rid, params: dict) -> dict:
|
|||
usage = _session_usage_snapshot(session)
|
||||
provider = getattr(agent, "provider", None) or mirror.get("provider") or "unknown"
|
||||
model = getattr(agent, "model", None) or mirror.get("model") or "(unknown)"
|
||||
project = _project_info_for_cwd(_display_session_cwd(session))
|
||||
lines = [
|
||||
"Hermes TUI Status",
|
||||
"",
|
||||
f"Session ID: {key}",
|
||||
f"Path: {display_hermes_home()}",
|
||||
]
|
||||
if project:
|
||||
lines.append(f"Project: {project['name']}")
|
||||
title = (meta.get("title") or "").strip()
|
||||
if title:
|
||||
lines.append(f"Title: {title}")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js'
|
||||
import { composeTabTitle, fmtCwdBranch, fmtProjectCwdBranch, shortCwd, shortProject } from '../domain/paths.js'
|
||||
|
||||
describe('shortCwd', () => {
|
||||
const origHome = process.env.HOME
|
||||
|
|
@ -69,6 +69,40 @@ describe('fmtCwdBranch', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('shortProject', () => {
|
||||
it('trims whitespace', () => {
|
||||
expect(shortProject(' website ')).toBe('website')
|
||||
})
|
||||
|
||||
it('truncates long project names from the right', () => {
|
||||
expect(shortProject('a-very-long-project-name', 10)).toBe('a-very-lo…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fmtProjectCwdBranch', () => {
|
||||
const origHome = process.env.HOME
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.HOME = '/Users/bb'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = origHome
|
||||
})
|
||||
|
||||
it('prefixes the cwd/branch label with the project name', () => {
|
||||
expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', 'website', 28)).toBe('website · ~/proj (main)')
|
||||
})
|
||||
|
||||
it('falls back to the cwd/branch label when no project is known', () => {
|
||||
expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', null, 28)).toBe('~/proj (main)')
|
||||
})
|
||||
|
||||
it('keeps the project visible when space is tight', () => {
|
||||
expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', 'hermes-agent', 12)).toBe('hermes-agent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeTabTitle', () => {
|
||||
it('joins marker, name, model, and cwd in order', () => {
|
||||
expect(composeTabTitle('✓', 'auth refactor', 'opus-4', '~/proj')).toBe('✓ auth refactor · opus-4 · ~/proj')
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { RESIZE_COALESCE_MS } from '../config/timing.js'
|
|||
import { hasLeadGap, prevRenderedMsg } from '../domain/blockLayout.js'
|
||||
import { SECTION_NAMES, sectionMode } from '../domain/details.js'
|
||||
import { attachedImageNotice, imageTokenMeta } from '../domain/messages.js'
|
||||
import { composeTabTitle, fmtCwdBranch, shortCwd } from '../domain/paths.js'
|
||||
import { composeTabTitle, fmtProjectCwdBranch, shortCwd } from '../domain/paths.js'
|
||||
import { sessionScopedModelArg } from '../domain/slash.js'
|
||||
import { type GatewayClient } from '../gatewayClient.js'
|
||||
import type {
|
||||
|
|
@ -1127,7 +1127,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||
// Cap the status-bar cwd/branch label tighter than the shared default so
|
||||
// it doesn't dominate the bar; the status rule reserves the left-side
|
||||
// essentials and truncates this further on narrow terminals.
|
||||
cwdLabel: fmtCwdBranch(cwd, gitBranch, 28),
|
||||
cwdLabel: fmtProjectCwdBranch(cwd, gitBranch, ui.info?.project?.name, 28),
|
||||
goodVibesTick,
|
||||
lastTurnEndedAt: ui.sid ? lastTurnEndedAt : null,
|
||||
sessionStartedAt: ui.sid ? sessionStartedAt : null,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,34 @@ export const fmtCwdBranch = (cwd: string, branch: null | string, max = 40) => {
|
|||
return `${shortCwd(cwd, Math.max(8, max - tag.length))}${tag}`
|
||||
}
|
||||
|
||||
export const shortProject = (projectName: string, max = 18) => {
|
||||
const name = projectName.trim()
|
||||
|
||||
return name.length <= max ? name : `${name.slice(0, Math.max(1, max - 1))}…`
|
||||
}
|
||||
|
||||
// Status-bar workspace label: the terminal has no hover tooltip, so the project
|
||||
// name is shown INLINE alongside the cwd/branch (`<project> · ~/cwd (branch)`).
|
||||
// Falls back to the plain cwd/branch label when the session sits in no named
|
||||
// project, and when space is tight the project name wins (it's the identity the
|
||||
// user recognizes) with the cwd/branch dropped.
|
||||
export const fmtProjectCwdBranch = (cwd: string, branch: null | string, projectName?: null | string, max = 40) => {
|
||||
const project = shortProject(projectName || '')
|
||||
|
||||
if (!project) {
|
||||
return fmtCwdBranch(cwd, branch, max)
|
||||
}
|
||||
|
||||
const separator = ' · '
|
||||
const remaining = max - project.length - separator.length
|
||||
|
||||
if (remaining < 8) {
|
||||
return shortProject(project, max)
|
||||
}
|
||||
|
||||
return `${project}${separator}${fmtCwdBranch(cwd, branch, remaining)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the terminal titlebar string:
|
||||
* `<marker> <session name> · <model> · <cwd>`
|
||||
|
|
|
|||
|
|
@ -149,6 +149,13 @@ export interface McpServerStatus {
|
|||
transport: string
|
||||
}
|
||||
|
||||
export interface ProjectInfo {
|
||||
id: string
|
||||
name: string
|
||||
primary_path?: null | string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
cwd?: string
|
||||
fast?: boolean
|
||||
|
|
@ -157,6 +164,7 @@ export interface SessionInfo {
|
|||
mcp_servers?: McpServerStatus[]
|
||||
model: string
|
||||
profile_name?: string
|
||||
project?: null | ProjectInfo
|
||||
reasoning_effort?: string
|
||||
release_date?: string
|
||||
service_tier?: string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue