Files
hapi/shared/src/sessionSummary.ts
T
8121d0df6e feat(web): fixed session order display setting (#2033)
* feat(web): fixed session order display setting

Add a per-device "Fixed session order" toggle (default off, Settings →
Display → Sessions). When on, the session list freezes: sessions sort
by creation time only (newest first) instead of re-sorting on
activity, workspace groups stop jumping (ordered by the newest session
created inside them), and the pin-in-progress running buckets are
paused. Manual pins still float to the top; search-relevance ordering
is unchanged.

- shared: SessionSummary gains an optional createdAt (toSessionSummary
  copies it). Older hubs simply omit it and the web falls back to
  updatedAt, degrading to the legacy activity order.
- web: sessionOrder lib (stable key + comparator), useFixedSessionOrder
  localStorage hook, useSSE cache sort via ref (no EventSource teardown
  on toggle), SessionList in-group/group/machine sorts plus bucket
  gating, settings switch, en/zh-CN/ru strings.
- tests: sessionOrder unit, useFixedSessionOrder hook, useSSE
  fixed-order cache cases, SessionList grouping cases, settings page
  switch, shared createdAt passthrough.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): tie-break equal order keys on session id

Review follow-up: sessions created in the same instant compared as equal,
so their relative order came from the incoming array order — which the
hub may derive from activity, making tied sessions visibly swap after
every refetch. Tie-break on session id to make the comparator a total
order. Two tests cover the comparator and the refetch-stability view.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(web): tie-break equal group creation instants on the group key

Review follow-up: the directory- and machine-level sorts compared only
the groups' latest creation instants, so groups created in the same
instant followed the incoming array order and could shuffle after every
refetch. Route both fixed-order group sorts through a shared
compareDescWithKeyTiebreak helper that falls back to the group's stable
key (directory groups: machine::path key; machine groups: machineId).
Test covers both incoming orders of two tied groups.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-10-26 10:27:52 +08:00

237 lines
8.2 KiB
TypeScript

import type { AgentState, Metadata, Session, TodoItem, WorktreeMetadata } from './schemas'
import { isKnownFlavor } from './flavors'
import type { AgentFlavor } from './modes'
export type PendingRequestKind = 'permission' | 'input'
const INPUT_REQUEST_TOOLS = new Set([
'AskUserQuestion',
'ask_user_question',
'ExitPlanMode',
'exit_plan_mode',
'request_user_input'
])
/** Cap on `pendingRequests` carried in `SessionSummary`. The list is meant for
* per-row hover copy ("Approve `Bash`, `Edit` (+1 more)"); deep inspection
* should use `Session.agentState.requests`. The `pendingRequestsCount` field
* is the authoritative total — `pendingRequests.length` may be smaller. */
export const PENDING_REQUEST_SUMMARY_CAP = 5
export type PendingRequest = {
id: string
kind: PendingRequestKind
tool: string
/** Epoch ms when the request was raised; falls back to the caller-supplied
* `fallbackSince` (typically `session.updatedAt`) for older requests
* stored without `createdAt`. */
since: number
}
function classifyKind(tool: string): PendingRequestKind {
return INPUT_REQUEST_TOOLS.has(tool) ? 'input' : 'permission'
}
export type SessionSummaryMetadata = {
name?: string
path: string
machineId?: string
summary?: { text: string }
flavor?: string | null
worktree?: WorktreeMetadata
agentSessionId?: string
lifecycleState?: string
/** Loopback MCP URL when session CLI happy server is running (#956). */
hapiMcpUrl?: string
}
export type SessionSummary = {
hasConversationContent?: boolean
id: string
active: boolean
thinking: boolean
activeAt: number
updatedAt: number
/** Epoch ms when the session was created. Drives the fixed-order session
* list sort. Optional because summaries from an older hub lack it —
* consumers fall back to `updatedAt` via the session-order key. */
createdAt?: number
pinned?: boolean
globalPinned?: boolean
metadata: SessionSummaryMetadata | null
/** Watermarks for structured SSE patches (PR #897). List cache must gate
* without requiring a detail query — otherwise global SSE forces O(N)
* /sessions invalidation for every versioned write. */
metadataVersion: number
agentStateVersion: number
todosUpdatedAt: number
todoProgress: { completed: number; total: number } | null
pendingRequestsCount: number
pendingRequestKinds: PendingRequestKind[]
/** Capped, oldest-first slice of pending tool requests. Use this for tooltip
* / per-row UX. The full count (which may exceed the cap) is in
* `pendingRequestsCount`. */
pendingRequests: PendingRequest[]
backgroundTaskCount: number
futureScheduledMessageCount: number
/** Epoch ms of the soonest uninvoked future scheduled message, or null. */
nextScheduledAt: number | null
model: string | null
modelReasoningEffort?: string | null
effort: string | null
}
// Re-exported as a standalone derivation so SSE patch handlers can recompute
// summary fields from a structured `agentState` patch without needing the
// full Session in hand.
export function computePendingRequestKinds(agentState: AgentState | null | undefined): PendingRequestKind[] {
const requests = agentState?.requests
if (!requests) {
return []
}
const kinds = new Set<PendingRequestKind>()
for (const request of Object.values(requests)) {
kinds.add(classifyKind(request.tool))
}
return kinds.has('permission') && kinds.has('input')
? ['permission', 'input']
: Array.from(kinds)
}
export function computePendingRequests(
agentState: AgentState | null | undefined,
fallbackSince: number,
cap: number = PENDING_REQUEST_SUMMARY_CAP
): PendingRequest[] {
const requests = agentState?.requests
if (!requests) {
return []
}
const items: PendingRequest[] = []
for (const [id, request] of Object.entries(requests)) {
items.push({
id,
kind: classifyKind(request.tool),
tool: request.tool,
since: typeof request.createdAt === 'number' ? request.createdAt : fallbackSince
})
}
items.sort((a, b) => {
if (a.since !== b.since) return a.since - b.since
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})
return cap >= items.length ? items : items.slice(0, cap)
}
export function getPendingRequestKinds(session: Session): PendingRequestKind[] {
return computePendingRequestKinds(session.agentState)
}
export function getPendingRequests(
session: Session,
cap: number = PENDING_REQUEST_SUMMARY_CAP
): PendingRequest[] {
return computePendingRequests(session.agentState, session.updatedAt, cap)
}
export function computePendingRequestsCount(agentState: AgentState | null | undefined): number {
return agentState?.requests ? Object.keys(agentState.requests).length : 0
}
export function computeTodoProgress(todos: TodoItem[] | undefined): SessionSummary['todoProgress'] {
if (!todos?.length) {
return null
}
return {
completed: todos.filter((todo) => todo.status === 'completed').length,
total: todos.length
}
}
const AGENT_SESSION_ID_FIELD_BY_FLAVOR: Partial<Record<AgentFlavor, keyof Metadata>> = {
claude: 'claudeSessionId',
codex: 'codexSessionId',
gemini: 'geminiSessionId',
opencode: 'opencodeSessionId',
grok: 'grokSessionId',
agy: 'agySessionId',
cursor: 'cursorSessionId',
kimi: 'kimiSessionId',
copilot: 'copilotSessionId',
pi: 'piSessionId'
}
function getSummaryAgentSessionId(metadata: Metadata): string | undefined {
const flavor = metadata.flavor
if (isKnownFlavor(flavor)) {
const flavorField = AGENT_SESSION_ID_FIELD_BY_FLAVOR[flavor]
if (!flavorField) return undefined
const flavorSessionId = metadata[flavorField]
return typeof flavorSessionId === 'string' && flavorSessionId.trim()
? flavorSessionId.trim()
: undefined
}
// Legacy fallback only applies when the stored flavor is missing or unknown.
return metadata.codexSessionId
?? metadata.claudeSessionId
?? metadata.geminiSessionId
?? metadata.opencodeSessionId
?? metadata.grokSessionId
?? metadata.agySessionId
?? metadata.cursorSessionId
?? metadata.kimiSessionId
?? metadata.copilotSessionId
?? undefined
}
export function toSessionSummaryMetadata(metadata: Metadata | null | undefined): SessionSummaryMetadata | null {
if (!metadata) {
return null
}
return {
name: metadata.name,
path: metadata.path,
machineId: metadata.machineId ?? undefined,
summary: metadata.summary ? { text: metadata.summary.text } : undefined,
flavor: metadata.flavor ?? null,
worktree: metadata.worktree,
agentSessionId: getSummaryAgentSessionId(metadata),
lifecycleState: metadata.lifecycleState,
hapiMcpUrl: metadata.hapiMcpUrl ?? undefined
}
}
export function toSessionSummary(session: Session): SessionSummary {
return {
hasConversationContent: session.hasConversationContent ?? false,
id: session.id,
active: session.active,
thinking: session.thinking,
activeAt: session.activeAt,
updatedAt: session.updatedAt,
createdAt: session.createdAt,
pinned: session.pinned ?? false,
globalPinned: session.globalPinned ?? false,
metadata: toSessionSummaryMetadata(session.metadata),
metadataVersion: session.metadataVersion,
agentStateVersion: session.agentStateVersion,
todosUpdatedAt: session.todosUpdatedAt ?? 0,
todoProgress: computeTodoProgress(session.todos),
pendingRequestsCount: computePendingRequestsCount(session.agentState),
pendingRequestKinds: computePendingRequestKinds(session.agentState),
pendingRequests: computePendingRequests(session.agentState, session.updatedAt),
backgroundTaskCount: session.backgroundTaskCount ?? 0,
futureScheduledMessageCount: 0,
nextScheduledAt: null,
model: session.model,
modelReasoningEffort: session.modelReasoningEffort,
effort: session.effort
}
}