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:
brooklyn! 2026-07-18 23:13:52 -04:00 committed by GitHub
commit f099b469de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 281 additions and 6 deletions

View file

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

View file

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

View file

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

View file

@ -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"]

View file

@ -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}")

View file

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

View file

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

View file

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

View file

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