Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions web/src/components/SessionList.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import type { Machine, SessionSummary } from '@/types/api'
import { getMachineLabel, groupSessionsByDirectory } from './sessionListUtils'

function createSession(overrides: Partial<SessionSummary> = {}): SessionSummary {
return {
id: overrides.id ?? 'session-1',
updatedAt: overrides.updatedAt ?? 1,
activeAt: overrides.activeAt ?? 1,
active: overrides.active ?? false,
pendingRequestsCount: overrides.pendingRequestsCount ?? 0,
thinking: overrides.thinking ?? false,
modelMode: overrides.modelMode ?? undefined,
todoProgress: overrides.todoProgress ?? null,
metadata: {
path: '/root/project-a',
machineId: 'machine-alpha-1234',
...overrides.metadata,
},
}
}

describe('SessionList helpers', () => {
it('separates groups by machine when directory is the same', () => {
const machinesById = new Map<string, Machine>([
['machine-alpha-1234', { id: 'machine-alpha-1234', active: true, metadata: { host: 'alpha-host', platform: 'linux', happyCliVersion: '0.16.1' } }],
['machine-beta-5678', { id: 'machine-beta-5678', active: true, metadata: { host: 'beta-host', platform: 'linux', happyCliVersion: '0.16.1' } }],
])
const sessions = [
createSession({
id: 'a',
metadata: {
path: '/root/project-a',
machineId: 'machine-alpha-1234',
},
}),
createSession({
id: 'b',
metadata: {
path: '/root/project-a',
machineId: 'machine-beta-5678',
},
}),
]

const groups = groupSessionsByDirectory(sessions, machinesById)

expect(groups).toHaveLength(2)
expect(groups.map(group => group.machineLabel)).toEqual(['alpha-host', 'beta-host'])
})

it('falls back to a short machine id when host is unavailable', () => {
const label = getMachineLabel(
'de5b5751-112d-42d4-b330-5b8ec3822cea',
new Map()
)

expect(label).toBe('de5b5751')
})
})
80 changes: 23 additions & 57 deletions web/src/components/SessionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,58 +8,9 @@ import { SessionActionMenu } from '@/components/SessionActionMenu'
import { RenameSessionDialog } from '@/components/RenameSessionDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { useTranslation } from '@/lib/use-translation'

type SessionGroup = {
directory: string
displayName: string
sessions: SessionSummary[]
latestUpdatedAt: number
hasActiveSession: boolean
}

function getGroupDisplayName(directory: string): string {
if (directory === 'Other') return directory
const parts = directory.split(/[\\/]+/).filter(Boolean)
if (parts.length === 0) return directory
if (parts.length === 1) return parts[0]
return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`
}

function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] {
const groups = new Map<string, SessionSummary[]>()

sessions.forEach(session => {
const path = session.metadata?.worktree?.basePath ?? session.metadata?.path ?? 'Other'
if (!groups.has(path)) {
groups.set(path, [])
}
groups.get(path)!.push(session)
})

return Array.from(groups.entries())
.map(([directory, groupSessions]) => {
const sortedSessions = [...groupSessions].sort((a, b) => {
const rankA = a.active ? (a.pendingRequestsCount > 0 ? 0 : 1) : 2
const rankB = b.active ? (b.pendingRequestsCount > 0 ? 0 : 1) : 2
if (rankA !== rankB) return rankA - rankB
return b.updatedAt - a.updatedAt
})
const latestUpdatedAt = groupSessions.reduce(
(max, s) => (s.updatedAt > max ? s.updatedAt : max),
-Infinity
)
const hasActiveSession = groupSessions.some(s => s.active)
const displayName = getGroupDisplayName(directory)

return { directory, displayName, sessions: sortedSessions, latestUpdatedAt, hasActiveSession }
})
.sort((a, b) => {
if (a.hasActiveSession !== b.hasActiveSession) {
return a.hasActiveSession ? -1 : 1
}
return b.latestUpdatedAt - a.latestUpdatedAt
})
}
import { getMachineLabel, groupSessionsByDirectory } from '@/components/sessionListUtils'
import type { SessionGroup } from '@/components/sessionListUtils'
import type { Machine } from '@/types/api'

function PlusIcon(props: { className?: string }) {
return (
Expand Down Expand Up @@ -163,13 +114,14 @@ function formatRelativeTime(value: number, t: (key: string, params?: Record<stri

function SessionItem(props: {
session: SessionSummary
machineLabel: string | null
onSelect: (sessionId: string) => void
showPath?: boolean
api: ApiClient | null
selected?: boolean
}) {
const { t } = useTranslation()
const { session: s, onSelect, showPath = true, api, selected = false } = props
const { session: s, machineLabel, onSelect, showPath = true, api, selected = false } = props
const { haptic } = usePlatform()
const [menuOpen, setMenuOpen] = useState(false)
const [menuAnchorPoint, setMenuAnchorPoint] = useState<{ x: number; y: number }>({ x: 0, y: 0 })
Expand Down Expand Up @@ -253,6 +205,9 @@ function SessionItem(props: {
</div>
) : null}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--app-hint)]">
{machineLabel ? (
<span>{t('session.item.machine')}: {machineLabel}</span>
) : null}
<span className="inline-flex items-center gap-2">
<span className="flex h-4 w-4 items-center justify-center" aria-hidden="true">
Expand Down Expand Up @@ -313,6 +268,7 @@ function SessionItem(props: {

export function SessionList(props: {
sessions: SessionSummary[]
machines?: Machine[]
onSelect: (sessionId: string) => void
onNewSession: () => void
onRefresh: () => void
Expand All @@ -322,10 +278,14 @@ export function SessionList(props: {
selectedSessionId?: string | null
}) {
const { t } = useTranslation()
const { renderHeader = true, api, selectedSessionId } = props
const { renderHeader = true, api, selectedSessionId, machines = [] } = props
const machinesById = useMemo(
() => new Map(machines.map(machine => [machine.id, machine])),
[machines]
)
const groups = useMemo(
() => groupSessionsByDirectory(props.sessions),
[props.sessions]
() => groupSessionsByDirectory(props.sessions, machinesById),
[props.sessions, machinesById]
)
const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
() => new Map()
Expand Down Expand Up @@ -382,7 +342,7 @@ export function SessionList(props: {
{groups.map((group) => {
const isCollapsed = isGroupCollapsed(group)
return (
<div key={group.directory}>
<div key={group.key}>
<button
type="button"
onClick={() => toggleGroup(group.directory, isCollapsed)}
Expand All @@ -396,6 +356,11 @@ export function SessionList(props: {
<span className="font-medium text-base break-words" title={group.directory}>
{group.displayName}
</span>
{group.machineLabel ? (
<span className="shrink-0 rounded-full border border-[var(--app-divider)] px-2 py-0.5 text-[10px] uppercase tracking-wide text-[var(--app-hint)]">
{group.machineLabel}
</span>
) : null}
<span className="shrink-0 text-xs text-[var(--app-hint)]">
({group.sessions.length})
</span>
Expand All @@ -407,6 +372,7 @@ export function SessionList(props: {
<SessionItem
key={s.id}
session={s}
machineLabel={getMachineLabel(s.metadata?.machineId, machinesById)}
onSelect={props.onSelect}
showPath={false}
api={api}
Expand Down
86 changes: 86 additions & 0 deletions web/src/components/sessionListUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { Machine, SessionSummary } from '@/types/api'

export type SessionGroup = {
key: string
directory: string
displayName: string
machineLabel: string | null
sessions: SessionSummary[]
latestUpdatedAt: number
hasActiveSession: boolean
}

function getGroupDisplayName(directory: string): string {
if (directory === 'Other') return directory
const parts = directory.split(/[\\/]+/).filter(Boolean)
if (parts.length === 0) return directory
if (parts.length === 1) return parts[0]
return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`
}

export function getMachineLabel(machineId: string | undefined, machinesById: ReadonlyMap<string, Machine>): string | null {
if (!machineId) return null

const machine = machinesById.get(machineId)
const displayName = machine?.metadata?.displayName?.trim()
if (displayName) return displayName

const host = machine?.metadata?.host?.trim()
if (host) return host

return machineId.slice(0, 8)
}

export function groupSessionsByDirectory(
sessions: SessionSummary[],
machinesById: ReadonlyMap<string, Machine>
): SessionGroup[] {
const groups = new Map<string, { directory: string; machineLabel: string | null; sessions: SessionSummary[] }>()

sessions.forEach(session => {
const path = session.metadata?.worktree?.basePath ?? session.metadata?.path ?? 'Other'
const machineId = session.metadata?.machineId
const machineLabel = getMachineLabel(machineId, machinesById)
const groupKey = `${machineId ?? 'unknown'}::${path}`
if (!groups.has(groupKey)) {
groups.set(groupKey, {
directory: path,
machineLabel,
sessions: [],
})
}
groups.get(groupKey)!.sessions.push(session)
})

return Array.from(groups.entries())
.map(([key, group]) => {
const sortedSessions = [...group.sessions].sort((a, b) => {
const rankA = a.active ? (a.pendingRequestsCount > 0 ? 0 : 1) : 2
const rankB = b.active ? (b.pendingRequestsCount > 0 ? 0 : 1) : 2
if (rankA !== rankB) return rankA - rankB
return b.updatedAt - a.updatedAt
})
const latestUpdatedAt = group.sessions.reduce(
(max, s) => (s.updatedAt > max ? s.updatedAt : max),
-Infinity
)
const hasActiveSession = group.sessions.some(s => s.active)
const displayName = getGroupDisplayName(group.directory)

return {
key,
directory: group.directory,
displayName,
machineLabel: group.machineLabel,
sessions: sortedSessions,
latestUpdatedAt,
hasActiveSession,
}
})
.sort((a, b) => {
if (a.hasActiveSession !== b.hasActiveSession) {
return a.hasActiveSession ? -1 : 1
}
return b.latestUpdatedAt - a.latestUpdatedAt
})
}
1 change: 1 addition & 0 deletions web/src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default {

// Session list
'session.item.path': 'path',
'session.item.machine': 'machine',
'session.item.agent': 'agent',
'session.item.model': 'model',
'session.item.modelMode': 'mode',
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default {

// Session list
'session.item.path': '路径',
'session.item.machine': '机器',
'session.item.agent': '代理',
'session.item.model': '模型',
'session.item.modelMode': '模型',
Expand Down
2 changes: 2 additions & 0 deletions web/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ function SessionsPage() {
const matchRoute = useMatchRoute()
const { t } = useTranslation()
const { sessions, isLoading, error, refetch } = useSessions(api)
const { machines } = useMachines(api, true)

const handleRefresh = useCallback(() => {
void refetch()
Expand Down Expand Up @@ -150,6 +151,7 @@ function SessionsPage() {
) : null}
<SessionList
sessions={sessions}
machines={machines}
selectedSessionId={selectedSessionId}
onSelect={(sessionId) => navigate({
to: '/sessions/$sessionId',
Expand Down