mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): inherit project color on session rows
Sessions that belong to a colored project now pick up that color as the sidebar row's idle lead dot, so work/personal/project buckets are legible at a glance (Layer 1 of #66565). Derived from the same project membership the sidebar already groups by; active states (working / needs-input / background / unread) still own the dot so the tint never fights an attention cue.
This commit is contained in:
parent
667b98b5cf
commit
5a6e235833
3 changed files with 96 additions and 4 deletions
|
|
@ -10,6 +10,7 @@ import {
|
|||
mergeRepoWorktreeGroups,
|
||||
overlayLiveLanes,
|
||||
overlayLivePreviews,
|
||||
sessionProjectColor,
|
||||
type SidebarProjectTree,
|
||||
type SidebarSessionGroup,
|
||||
sortWorktreeGroups
|
||||
|
|
@ -519,6 +520,46 @@ describe('liveSessionProjectId', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('sessionProjectColor', () => {
|
||||
const colored = (id: string, folders: string[], color: string): ProjectInfo => ({
|
||||
...makeProject(id, folders),
|
||||
color
|
||||
})
|
||||
|
||||
it('inherits the color of the explicit project the session belongs to', () => {
|
||||
const session = makeSession('/www/app/src', { git_repo_root: '/www/app' })
|
||||
|
||||
expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff')
|
||||
})
|
||||
|
||||
it('returns null when the owning project has no color set', () => {
|
||||
const session = makeSession('/www/app/src', { git_repo_root: '/www/app' })
|
||||
|
||||
expect(sessionProjectColor(session, [makeProject('p_app', ['/www/app'])])).toBeNull()
|
||||
})
|
||||
|
||||
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.
|
||||
expect(sessionProjectColor(makeSession('/www/app'), [])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for an unplaceable (cwd-less) session', () => {
|
||||
expect(sessionProjectColor(makeSession(null), [colored('p_app', ['/www/app'], '#4a9eff')])).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the longest-prefix project when nested projects both match', () => {
|
||||
const session = makeSession('/www/app/packages/api/src', { git_repo_root: '/www/app' })
|
||||
|
||||
const projects = [
|
||||
colored('p_root', ['/www/app'], '#111111'),
|
||||
colored('p_api', ['/www/app/packages/api'], '#222222')
|
||||
]
|
||||
|
||||
expect(sessionProjectColor(session, projects)).toBe('#222222')
|
||||
})
|
||||
})
|
||||
|
||||
describe('overlayLiveLanes', () => {
|
||||
it('injects a live session into the matching main lane instantly', () => {
|
||||
const project = projectNode({
|
||||
|
|
|
|||
|
|
@ -396,6 +396,26 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
|
|||
return projectId || repoRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* The color a session inherits from its owning project — the explicit project
|
||||
* whose folder is the longest prefix of the session's cwd/repo-root, when that
|
||||
* project carries a user-set color. Auto-promoted repo projects have no color
|
||||
* unless the user set one, so a session only tints when it belongs to a colored
|
||||
* project (inheritance is opt-in by coloring the project). Reuses
|
||||
* {@link liveSessionProjectId} so the color follows the SAME membership the
|
||||
* sidebar groups by; returns null for cwd-less / kanban / out-of-tree rows and
|
||||
* for sessions under an uncolored (or auto) project.
|
||||
*/
|
||||
export function sessionProjectColor(session: SessionInfo, projects: ProjectInfo[]): null | string {
|
||||
const projectId = liveSessionProjectId(session, projects)
|
||||
|
||||
if (!projectId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return projects.find(project => project.id === projectId)?.color ?? null
|
||||
}
|
||||
|
||||
const upsertSession = (rows: SessionInfo[], session: SessionInfo): SessionInfo[] =>
|
||||
[session, ...rows.filter(row => row.id !== session.id)].sort((a, b) => b.started_at - a.started_at)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,13 @@ import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
|||
import { coarseElapsed } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $backgroundRunningSessionIds } from '@/store/composer-status'
|
||||
import { $projects } from '@/store/projects'
|
||||
import { $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
|
||||
import { sessionProjectColor } from './projects/workspace-groups'
|
||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||
import { useProfilePrewarm } from './use-profile-prewarm'
|
||||
|
||||
|
|
@ -91,6 +93,9 @@ export function SidebarSessionRow({
|
|||
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
|
||||
// True when a terminal(background=true) process is alive in this session.
|
||||
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
|
||||
// The color inherited from the session's project (idle dot tint). Follows the
|
||||
// same membership the sidebar groups by; null unless the project is colored.
|
||||
const projectColor = sessionProjectColor(session, useStore($projects))
|
||||
|
||||
// Resolve the dot's display state once — the four signals are mutually
|
||||
// exclusive by priority, so threading them as booleans through wrappers just
|
||||
|
|
@ -240,11 +245,12 @@ export function SidebarSessionRow({
|
|||
branchStem={branchStem}
|
||||
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
|
||||
dotState={dotState}
|
||||
projectColor={projectColor}
|
||||
/>
|
||||
</SidebarRowGrab>
|
||||
) : (
|
||||
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
|
||||
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} />
|
||||
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} projectColor={projectColor} />
|
||||
</SidebarRowLead>
|
||||
)}
|
||||
{handoffSource && handoffLabel ? (
|
||||
|
|
@ -274,11 +280,13 @@ type SessionDotState = 'background' | 'idle' | 'needs-input' | 'unread' | 'worki
|
|||
function SessionRowLeadDot({
|
||||
branchStem,
|
||||
dotState = 'idle',
|
||||
className
|
||||
className,
|
||||
projectColor
|
||||
}: {
|
||||
branchStem?: string
|
||||
dotState?: SessionDotState
|
||||
className?: string
|
||||
projectColor?: null | string
|
||||
}) {
|
||||
return (
|
||||
<span className={cn('flex items-center gap-0.5', className)}>
|
||||
|
|
@ -287,7 +295,7 @@ function SessionRowLeadDot({
|
|||
{branchStem}
|
||||
</span>
|
||||
) : null}
|
||||
<SidebarRowDot dotState={dotState} />
|
||||
<SidebarRowDot dotState={dotState} projectColor={projectColor} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -348,9 +356,32 @@ const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
|
|||
}
|
||||
}
|
||||
|
||||
function SidebarRowDot({ dotState, className }: { dotState: SessionDotState; className?: string }) {
|
||||
function SidebarRowDot({
|
||||
dotState,
|
||||
className,
|
||||
projectColor
|
||||
}: {
|
||||
dotState: SessionDotState
|
||||
className?: string
|
||||
projectColor?: null | string
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
|
||||
// An idle session inherits its project's color (a quiet marker matching the
|
||||
// project row's own color dot). The active states (working / needs-input /
|
||||
// background / unread) own the dot and keep their semantic color, so the
|
||||
// inherited tint never competes with an attention cue.
|
||||
if (dotState === 'idle' && projectColor) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('size-1 rounded-full', className)}
|
||||
style={{ backgroundColor: projectColor }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const variant = DOT_VARIANTS[dotState]
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue