feat(desktop): nest profile sessions under gateway sections
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user