mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
fix(desktop): resolve tab title and project color for sessions outside the recents page
Opening a session in a new tab left the tab titled 'Session' with no project-color dot until new activity landed the row in the paginated recents list. Two gaps: 1. tileTitle/tileAccent/tileDragPayload only looked the stored row up in $sessions (the recents page). Sessions opened from a project group are often older than that page, so the lookup missed entirely. Resolve through the project tree as a fallback (tileStoredRow) and re-sync pane titles/accents when $projectTree loads. 2. Even with the row present, liveSessionProjectId returned null for a session whose cwd sits outside its recorded git_repo_root (mid-session relocation / sibling worktree), because the cwd-under-root guard ran before the explicit-project folder match. The backend tree groups such rows under the project; the client now agrees — an explicit folder match is authoritative, only the auto-project (repo root) fallback still needs cwd-under-root confidence. sessionColorFor also computes directly (overrides -> project color) when the row isn't in the $sessionColorById map, which is keyed over $sessions only.
This commit is contained in:
parent
35cdc63ca0
commit
bf21ba08e3
4 changed files with 102 additions and 17 deletions
|
|
@ -36,6 +36,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
|
|||
import { createComposerAttachmentScope } from '@/store/composer'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $projectTree } from '@/store/projects'
|
||||
import { sessionAwaitingInput } from '@/store/prompts'
|
||||
import {
|
||||
$gatewayState,
|
||||
|
|
@ -54,6 +55,7 @@ import {
|
|||
type SessionTile,
|
||||
sessionTileDelegate
|
||||
} from '@/store/session-states'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import type { SessionDragPayload } from './composer/inline-refs'
|
||||
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
|
||||
|
|
@ -278,8 +280,41 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
|
|||
// Tile -> pane contribution sync (call once from the app root).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve a tile's stored row: the recents list first, then the project
|
||||
* tree. A session opened as a tab from a project group is often older than
|
||||
* the paginated recents page, so it has no `$sessions` row at all until new
|
||||
* activity lands it there — resolving through the tree keeps its tab titled
|
||||
* and tinted instead of a grey "Session" placeholder. */
|
||||
export function tileStoredRow(storedSessionId: string): SessionInfo | undefined {
|
||||
const fromList = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
|
||||
if (fromList) {
|
||||
return fromList
|
||||
}
|
||||
|
||||
for (const project of $projectTree.get()) {
|
||||
for (const repo of project.repos) {
|
||||
for (const group of repo.groups) {
|
||||
const row = group.sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
|
||||
if (row) {
|
||||
return row
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preview = project.previewSessions?.find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
|
||||
if (preview) {
|
||||
return preview
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function tileTitle(storedSessionId: string): string {
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
|
||||
return stored ? sessionTitle(stored) : 'Session'
|
||||
}
|
||||
|
|
@ -287,12 +322,12 @@ function tileTitle(storedSessionId: string): string {
|
|||
/** The tab's lead-dot color — the tile's session resolved through the SAME
|
||||
* shared map the sidebar reads, so a row and its tab always agree. */
|
||||
function tileAccent(storedSessionId: string): string | undefined {
|
||||
return sessionColorFor($sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)))
|
||||
return sessionColorFor(tileStoredRow(storedSessionId))
|
||||
}
|
||||
|
||||
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
|
||||
function tileDragPayload(storedSessionId: string): SessionDragPayload {
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
|
||||
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
|
||||
}
|
||||
|
|
@ -381,9 +416,10 @@ export function SessionTabMenu({
|
|||
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
|
||||
tabPaneId: string
|
||||
}) {
|
||||
const sessions = useStore($sessions)
|
||||
useStore($sessions)
|
||||
useStore($projectTree)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
const pinId = stored ? sessionPinId(stored) : storedSessionId
|
||||
const pinned = pinnedSessionIds.includes(pinId)
|
||||
|
||||
|
|
@ -440,7 +476,9 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
|
|||
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
|
||||
export const watchSessionTiles = paneMirror<SessionTile>({
|
||||
source: $sessionTiles,
|
||||
also: [$sessions, $sessionColorById],
|
||||
// $projectTree: a tile whose session is older than the recents page resolves
|
||||
// its title/accent through the tree, which loads after the tiles register.
|
||||
also: [$sessions, $sessionColorById, $projectTree],
|
||||
key: t => t.storedSessionId,
|
||||
prefix: 'session-tile',
|
||||
dir: t => t.dir,
|
||||
|
|
|
|||
|
|
@ -505,6 +505,24 @@ describe('liveSessionProjectId', () => {
|
|||
expect(id).toBe('p_app')
|
||||
})
|
||||
|
||||
it('places a cwd-outside-root session under an explicit project matching either path', () => {
|
||||
// A mid-session relocation (or a sibling worktree) leaves cwd outside the
|
||||
// recorded repo root. An explicit folder match is still authoritative —
|
||||
// only the auto-project (repo root) fallback needs cwd-under-root
|
||||
// confidence. Match via the repo root...
|
||||
expect(
|
||||
liveSessionProjectId(makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [
|
||||
makeProject('p_proj', ['/home/u/proj'])
|
||||
])
|
||||
).toBe('p_proj')
|
||||
// ...and via the cwd.
|
||||
expect(
|
||||
liveSessionProjectId(makeSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [
|
||||
makeProject('p_www', ['/www/elsewhere'])
|
||||
])
|
||||
).toBe('p_www')
|
||||
})
|
||||
|
||||
it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => {
|
||||
// The bug: a fresh Windows session drops into the overlay before the next
|
||||
// backend refresh; case-sensitive matching missed its project until then.
|
||||
|
|
@ -553,6 +571,14 @@ describe('sessionProjectColor', () => {
|
|||
expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff')
|
||||
})
|
||||
|
||||
it('colors a cwd-outside-root session when an explicit project folder matches', () => {
|
||||
// The backend tree groups such a row under the project; the client color
|
||||
// derivation must agree instead of leaving the row (and its tab) grey.
|
||||
const session = makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' })
|
||||
|
||||
expect(sessionProjectColor(session, [colored('p_proj', ['/home/u/proj'], '#4a9eff')])).toBe('#4a9eff')
|
||||
})
|
||||
|
||||
it('returns null for a session that only maps to an auto repo root (no explicit project)', () => {
|
||||
// liveSessionProjectId falls back to the repo root id, which is not a
|
||||
// project row and therefore carries no color.
|
||||
|
|
|
|||
|
|
@ -357,7 +357,11 @@ function isPathUnder(folder: string, target: string): boolean {
|
|||
* the overview at once instead of waiting for the next backend refresh. Returns
|
||||
* null only for sessions we genuinely can't place from the row alone: cwd-less,
|
||||
* kanban-task worktrees (they fold into the kanban bucket), or a worktree that
|
||||
* lives OUTSIDE the repo root (a sibling dir whose project can't be derived).
|
||||
* lives OUTSIDE the repo root (a sibling dir) AND under no explicit project
|
||||
* folder. An explicit-project folder match always places the row — even when
|
||||
* the row's cwd sits outside its recorded repo root (a mid-session relocation,
|
||||
* or a sibling worktree of a project repo), the folder match is authoritative;
|
||||
* only the repo-root AUTO-project fallback needs cwd-under-root confidence.
|
||||
*/
|
||||
export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string {
|
||||
const cwd = (session.cwd || '').trim()
|
||||
|
|
@ -372,13 +376,6 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
|
|||
return null
|
||||
}
|
||||
|
||||
// With a cwd present it must sit under the repo root (a sibling worktree
|
||||
// outside the root can't be placed from the row alone); a root-only session
|
||||
// skips this — the root IS the anchor.
|
||||
if (cwd && !isPathUnder(repoRoot, cwd)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let projectId = ''
|
||||
let bestLen = -1
|
||||
|
||||
|
|
@ -399,7 +396,19 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
|
|||
}
|
||||
}
|
||||
|
||||
return projectId || repoRoot
|
||||
if (projectId) {
|
||||
return projectId
|
||||
}
|
||||
|
||||
// AUTO-project fallback (the repo root itself): with a cwd present it must
|
||||
// sit under the repo root (a sibling worktree outside the root can't be
|
||||
// placed from the row alone); a root-only session skips this — the root IS
|
||||
// the anchor.
|
||||
if (cwd && !isPathUnder(repoRoot, cwd)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return repoRoot
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -57,7 +57,19 @@ export const $sessionColorById = computed(
|
|||
)
|
||||
|
||||
// The color for a single session object (the tabs already hold the SessionInfo
|
||||
// they render, so they resolve through the same map the sidebar reads).
|
||||
// they render, so they resolve through the same map the sidebar reads). A row
|
||||
// that isn't in `$sessions` — e.g. a project-tree session older than the
|
||||
// paginated recents page, opened as a tab — misses the map, so fall back to
|
||||
// resolving it directly through the same precedence the map uses.
|
||||
export function sessionColorFor(session: null | SessionInfo | undefined): string | undefined {
|
||||
return session ? $sessionColorById.get()[session.id] : undefined
|
||||
if (!session) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (
|
||||
$sessionColorById.get()[session.id] ??
|
||||
$sessionColorOverrides.get()[sessionPinId(session)] ??
|
||||
sessionProjectColor(session, $projects.get()) ??
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue