feat(desktop): nest profile sessions under gateway sections

This commit is contained in:
Teknium
2026-09-08 04:49:10 -07:00
parent f6449ec37c
commit a529ecfeb5
3 changed files with 140 additions and 47 deletions

View File

@@ -83,18 +83,37 @@ it('keeps equal profile names on separate gateways and routes section creation a
.closest('[data-gateway-group]')
?.getAttribute('data-gateway-group')
).toBe(JSON.stringify([null, 'default']))
expect(screen.getByText('This computer · default')).toBeTruthy()
expect(screen.getByText('Homelab · default')).toBeTruthy()
expect(screen.getByText('Cloud workspace · default')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New session in Homelab · default' }))
act(() =>
$sessions.set([
...$sessions.get(),
makeSessionInfo({
id: 'remote-work',
connection_id: 'remote-1',
profile: 'work',
title: 'Work session',
last_active: Date.now() / 1000
})
])
)
const gateway = screen.getByText('Homelab').closest('[data-gateway-section]') as HTMLElement
expect(within(gateway).getByText('default')).toBeTruthy()
expect(within(gateway).getByText('work')).toBeTruthy()
expect(gateway.querySelectorAll('[data-gateway-group]')).toHaveLength(2)
fireEvent.click(within(gateway).getAllByRole('button', { name: 'New session in default' })[0])
expect($newChatRoute.get()).toMatchObject({ connectionId: 'remote-1', profile: 'default' })
fireEvent.click(screen.getByText('cloud-1 session'))
expect(resume).toHaveBeenLastCalledWith(
'cloud-1',
expect.objectContaining({ connection_id: 'cloud-1', profile: 'default' })
)
const group = screen.getByText('Homelab · default').closest('[data-gateway-group]')!
fireEvent.click(within(group as HTMLElement).getByRole('button', { name: 'Hide Homelab · default sessions' }))
const group = within(gateway).getByText('default').closest('[data-gateway-group]')!
fireEvent.click(within(group as HTMLElement).getByRole('button', { name: 'Hide default sessions' }))
expect(screen.queryByText('remote-1 session')).toBeNull()
expect(screen.getByText('Work session')).toBeTruthy()
fireEvent.click(within(gateway).getByRole('button', { name: 'Hide Homelab sessions' }))
expect(screen.queryByText('Work session')).toBeNull()
fireEvent.click(within(gateway).getByRole('button', { name: 'Show Homelab sessions' }))
expect(screen.getByText('Work session')).toBeTruthy()
expect(screen.queryByText('remote-1 session')).toBeNull()
expect(screen.getByText('local session')).toBeTruthy()
})

View File

@@ -21,6 +21,7 @@ import { ProfileGlyph } from '@/components/ui/profile-glyph'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { useStoreSelector } from '@/lib/use-session-slice'
import { $connectionsRegistry } from '@/store/connection-registry-state'
import { newSessionInAgent, newSessionInProfile } from '@/store/profile'
import { $sessionProfilesUsage } from '@/store/session'
import { $sidebarSessionRankIds } from '@/store/sidebar-sort'
@@ -45,12 +46,49 @@ interface GatewayProfileGroupsProps {
renderRows: (sessions: SessionInfo[]) => ReactNode
sensors?: ReturnType<typeof useSensors>
onNewSessionSplit?: NewSessionSplitHandler
nested?: boolean
}
export function GatewayProfileGroups({ groups, renderRows, sensors, onNewSessionSplit }: GatewayProfileGroupsProps) {
export function GatewayProfileGroups({
groups,
renderRows,
sensors,
onNewSessionSplit,
nested = false
}: GatewayProfileGroupsProps) {
const registry = useStore($connectionsRegistry)
const order = useStore($gatewayGroupOrder)
const gatewayProfiles = new Map<string, SidebarSessionGroup[]>()
const sections: SidebarSessionGroup[] = []
const ordered = [...groups].sort((a, b) => {
for (const group of groups) {
// Unknown legacy ownership stays unassigned; never guess a local gateway.
if (nested || !group.connectionId) {
sections.push(nested ? { ...group, label: group.profile! } : group)
continue
}
const id = JSON.stringify(['gateway', group.connectionId])
const profiles = gatewayProfiles.get(id)
if (profiles) {
profiles.push(group)
} else {
gatewayProfiles.set(id, [group])
sections.push({
id,
connectionId: group.connectionId,
label:
registry?.connections.find(connection => connection.id === group.connectionId)?.label || group.connectionId,
mode: 'profile',
path: null,
sessions: []
})
}
}
const ordered = [...sections].sort((a, b) => {
const left = order.indexOf(a.id)
const right = order.indexOf(b.id)
@@ -70,7 +108,19 @@ export function GatewayProfileGroups({ groups, renderRows, sensors, onNewSession
onMove={direction => reorderGatewayGroups(arrayMove(ids, index, index + direction))}
onNewSessionSplit={onNewSessionSplit}
renderRows={renderRows}
/>
>
{gatewayProfiles.has(group.id) && (
<div className="ml-3 border-l border-border/50 pl-1">
<GatewayProfileGroups
groups={gatewayProfiles.get(group.id)!}
nested
onNewSessionSplit={onNewSessionSplit}
renderRows={renderRows}
sensors={sensors}
/>
</div>
)}
</GatewayProfileGroup>
))}
</ReorderableList>
)
@@ -83,9 +133,18 @@ interface GatewayProfileGroupProps {
onNewSessionSplit?: NewSessionSplitHandler
first: boolean
last: boolean
children?: ReactNode
}
function GatewayProfileGroup({ group, renderRows, onMove, first, last, onNewSessionSplit }: GatewayProfileGroupProps) {
function GatewayProfileGroup({
group,
renderRows,
onMove,
first,
last,
onNewSessionSplit,
children
}: GatewayProfileGroupProps) {
const { t } = useI18n()
const s = t.sidebar
const copy = s.gatewayGroups
@@ -120,35 +179,42 @@ function GatewayProfileGroup({ group, renderRows, onMove, first, last, onNewSess
}
return (
<SidebarRowStack data-gateway-group={group.id} ref={sortable.ref} style={sortable.style}>
<SidebarRowStack
data-gateway-group={group.profile ? group.id : undefined}
data-gateway-section={!group.profile ? group.id : undefined}
ref={sortable.ref}
style={sortable.style}
>
<SidebarGroupRow
actions={
<div className="flex items-center">
<WorkspaceAddButton
label={s.newSessionIn(label)}
onClick={startSession}
onPointerDown={
onNewSessionSplit
? event =>
startNewSessionDrag(
placement => {
if (!open) {
toggleGatewayGroup(group.id)
}
{group.profile && (
<WorkspaceAddButton
label={s.newSessionIn(label)}
onClick={startSession}
onPointerDown={
onNewSessionSplit
? event =>
startNewSessionDrag(
placement => {
if (!open) {
toggleGatewayGroup(group.id)
}
onNewSessionSplit(placement.dir, {
anchor: placement.anchor,
before: placement.before,
profile: group.profile,
route
})
},
event,
{ label: s.newSessionIn(label), profile: group.profile, route }
)
: undefined
}
/>
onNewSessionSplit(placement.dir, {
anchor: placement.anchor,
before: placement.before,
profile: group.profile,
route
})
},
event,
{ label: s.newSessionIn(label), profile: group.profile, route }
)
: undefined
}
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button aria-label={`${copy.actions}: ${label}`} size="icon-xs" variant="ghost">
@@ -188,12 +254,16 @@ function GatewayProfileGroup({ group, renderRows, onMove, first, last, onNewSess
dragging={sortable.dragging}
dragHandleProps={sortable.dragHandleProps}
>
<ProfileGlyph
className="size-full"
color={group.color ?? null}
isDefault={group.profile === 'default'}
name={group.profile!}
/>
{group.profile ? (
<ProfileGlyph
className="size-full"
color={group.color ?? null}
isDefault={group.profile === 'default'}
name={group.profile}
/>
) : (
<Codicon name="remote" />
)}
</SidebarRowGrab>
}
toggle={{ ariaLabel: s.projects.toggle(label, !open), onToggle: () => toggleGatewayGroup(group.id), open }}
@@ -201,6 +271,7 @@ function GatewayProfileGroup({ group, renderRows, onMove, first, last, onNewSess
/>
{open && (
<>
{children}
{renderRows(sessions.slice(0, visibleCount))}
{hiddenCount > 0 && (
<WorkspaceShowMoreButton

View File

@@ -84,16 +84,19 @@ does not switch the current workspace.
## Organizing session groups
In the Sessions sidebar's view menu, choose **Gateway & profile** while viewing
all profiles. Each gateway/profile pair gets its own collapsible section, so two
gateways with a `default` profile no longer share a section. Section labels start
as the gateway name followed by the profile name.
all profiles. Each gateway gets its own collapsible section, with profile
subsections containing their sessions. Two gateways with a `default` profile
stay separate. Gateway headers start with the saved connection name; profile
headers show the profile name.
Use a section's menu to **Rename group**, **Reset name**, **Move up**, or
Use a gateway or profile section's menu to **Rename group**, **Reset name**, **Move up**, or
**Move down**. Renaming changes only the sidebar label, not the gateway or profile.
Gateways reorder as complete sections, and profiles reorder within their own gateway.
Drag the section's leading icon to reorder it, or focus that handle and use
Space, arrow keys, then Space to place it. Names, order, and collapsed sections
are remembered on this desktop. The section's new-session action targets that
section's gateway and profile.
are remembered on this desktop. Collapsing a gateway preserves its profiles'
individual collapse states. Each profile's new-session action targets that
profile on its owning gateway.
The Hermes Cloud panel also lists **Saved Cloud gateways** when portal discovery
is signed out. **Use gateway** selects an existing saved connection without