Skip to content

Commit d530ee2

Browse files
yoyowormsHAPI
andcommitted
feat: session list UX — recent-12h section, cross-device pin, unread badge
Three additive features on the session list: 1. Recent (last 12h) section A flat list at the top of the session list, showing every session updated in the last 12 hours regardless of project, sorted active- first then by recency. Same SessionItem rendering as folder rows with showPath=true so users can tell which project each entry belongs to. Hidden during search. 2. Pin sessions (cross-device, server-side) Long-press a session and pick "置顶 / Pin to top" to keep it at the top of Recent permanently, even past the 12h window. Pin state lives on the server in session metadata (metadata.pinnedAt: epoch ms) so it syncs across browser tabs / devices via SSE. Cleared by tapping the same menu item again. - Hub: sessionCache.pinSession + syncEngine wrapper - Hub: POST /api/sessions/:id/pin { pinned: boolean } - Shared: PinSessionRequestSchema, pinnedAt on MetadataSchema + SessionSummaryMetadata (copied through toSessionSummary) - Web: usePinnedSessions hook derives from session metadata, uses useMutation to call the API; sort + display in SessionList 3. Unread badge — synced with push notification triggers When NotificationHub fires a ready / permission / task / session- completion push, it also fire-and-forgets a markSessionUnread on the syncEngine, which writes metadata.unreadAt = Date.now() with a version-mismatch retry. Sessions with unreadAt show a small #007AFF dot before the title in the list. When SessionChat mounts (or while it's open and a new unreadAt arrives) it calls POST /api/sessions/:id/mark-read which clears the flag and SSE broadcasts the cleared state to every device. Build: 44 → 48. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
1 parent 5b8cf2c commit d530ee2

18 files changed

Lines changed: 539 additions & 5 deletions

hub/src/notifications/notificationHub.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
99
class FakeSyncEngine {
1010
private readonly listeners: Set<SyncEventListener> = new Set()
1111
private readonly sessions: Map<string, Session> = new Map()
12+
readonly unreadMarks: string[] = []
13+
readonly readMarks: string[] = []
1214

1315
subscribe(listener: SyncEventListener): () => void {
1416
this.listeners.add(listener)
@@ -23,6 +25,14 @@ class FakeSyncEngine {
2325
this.sessions.set(session.id, session)
2426
}
2527

28+
async markSessionUnread(sessionId: string): Promise<void> {
29+
this.unreadMarks.push(sessionId)
30+
}
31+
32+
async markSessionRead(sessionId: string): Promise<void> {
33+
this.readMarks.push(sessionId)
34+
}
35+
2636
emit(event: SyncEvent): void {
2737
for (const listener of this.listeners) {
2838
listener(event)
@@ -122,6 +132,45 @@ describe('NotificationHub', () => {
122132
hub.stop()
123133
})
124134

135+
it('marks session unread when a ready notification fires', async () => {
136+
const engine = new FakeSyncEngine()
137+
const channel = new StubChannel()
138+
const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], {
139+
permissionDebounceMs: 1,
140+
readyCooldownMs: 1
141+
})
142+
143+
const session = createSession()
144+
engine.setSession(session)
145+
146+
const readyEvent: SyncEvent = {
147+
type: 'message-received',
148+
sessionId: session.id,
149+
message: {
150+
id: 'message-1',
151+
seq: 1,
152+
localId: null,
153+
createdAt: 0,
154+
content: {
155+
role: 'agent',
156+
content: {
157+
id: 'event-1',
158+
type: 'event',
159+
data: { type: 'ready' }
160+
}
161+
}
162+
}
163+
}
164+
165+
engine.emit(readyEvent)
166+
await sleep(5)
167+
168+
expect(channel.readySessions).toHaveLength(1)
169+
expect(engine.unreadMarks).toEqual([session.id])
170+
171+
hub.stop()
172+
})
173+
125174
it('throttles ready notifications per session', async () => {
126175
const engine = new FakeSyncEngine()
127176
const channel = new StubChannel()

hub/src/notifications/notificationHub.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,16 @@ export class NotificationHub {
220220
await this.notifySessionCompletion(session, reason)
221221
}
222222

223+
// Mark the session as unread on the same triggers that fire a push.
224+
// Fire-and-forget so a metadata write failure doesn't disrupt the push path.
225+
private markSessionUnreadAsync(sessionId: string): void {
226+
this.syncEngine.markSessionUnread(sessionId).catch((error) => {
227+
console.error('[NotificationHub] Failed to mark session unread:', error)
228+
})
229+
}
230+
223231
private async notifyReady(session: Session): Promise<void> {
232+
this.markSessionUnreadAsync(session.id)
224233
for (const channel of this.channels) {
225234
try {
226235
await channel.sendReady(session)
@@ -231,6 +240,7 @@ export class NotificationHub {
231240
}
232241

233242
private async notifyPermission(session: Session): Promise<void> {
243+
this.markSessionUnreadAsync(session.id)
234244
for (const channel of this.channels) {
235245
try {
236246
await channel.sendPermissionRequest(session)
@@ -241,6 +251,7 @@ export class NotificationHub {
241251
}
242252

243253
private async notifyTask(session: Session, notification: TaskNotification): Promise<void> {
254+
this.markSessionUnreadAsync(session.id)
244255
for (const channel of this.channels) {
245256
try {
246257
await channel.sendTaskNotification(session, notification)
@@ -251,6 +262,7 @@ export class NotificationHub {
251262
}
252263

253264
private async notifySessionCompletion(session: Session, reason: SessionEndReason): Promise<void> {
265+
this.markSessionUnreadAsync(session.id)
254266
for (const channel of this.channels) {
255267
if (typeof channel.sendSessionCompletion !== 'function') {
256268
continue

hub/src/sync/sessionCache.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,104 @@ export class SessionCache {
506506
this.refreshSession(sessionId)
507507
}
508508

509+
async pinSession(sessionId: string, pinned: boolean): Promise<void> {
510+
const session = this.sessions.get(sessionId)
511+
if (!session) {
512+
throw new Error('Session not found')
513+
}
514+
515+
const currentMetadata = session.metadata ?? { path: '', host: '' }
516+
const { pinnedAt: _existing, ...rest } = currentMetadata as { pinnedAt?: number | null } & Record<string, unknown>
517+
const newMetadata = pinned
518+
? { ...rest, pinnedAt: Date.now() }
519+
: rest
520+
521+
const result = this.store.sessions.updateSessionMetadata(
522+
sessionId,
523+
newMetadata as typeof currentMetadata,
524+
session.metadataVersion,
525+
session.namespace,
526+
{ touchUpdatedAt: false }
527+
)
528+
529+
if (result.result === 'error') {
530+
throw new Error('Failed to update session metadata')
531+
}
532+
533+
if (result.result === 'version-mismatch') {
534+
throw new Error('Session was modified concurrently. Please try again.')
535+
}
536+
537+
this.refreshSession(sessionId)
538+
}
539+
540+
// Mark a session as having "unread" activity that the user should look at.
541+
// Triggered by the same events that fire a push notification (ready,
542+
// permission request, task notification, session completion). Best-effort:
543+
// we retry once on a version-mismatch and otherwise swallow errors so the
544+
// notification path doesn't fail because of metadata churn.
545+
async markSessionUnread(sessionId: string): Promise<void> {
546+
for (let attempt = 0; attempt < 2; attempt += 1) {
547+
const session = this.sessions.get(sessionId)
548+
if (!session) return
549+
550+
const currentMetadata = session.metadata ?? { path: '', host: '' }
551+
if ((currentMetadata as { unreadAt?: number | null }).unreadAt) {
552+
return
553+
}
554+
555+
const newMetadata = { ...currentMetadata, unreadAt: Date.now() }
556+
557+
const result = this.store.sessions.updateSessionMetadata(
558+
sessionId,
559+
newMetadata,
560+
session.metadataVersion,
561+
session.namespace,
562+
{ touchUpdatedAt: false }
563+
)
564+
565+
if (result.result === 'success') {
566+
this.refreshSession(sessionId)
567+
return
568+
}
569+
if (result.result === 'error') {
570+
return
571+
}
572+
this.refreshSession(sessionId)
573+
// version-mismatch: loop and try again with the refreshed version
574+
}
575+
}
576+
577+
async markSessionRead(sessionId: string): Promise<void> {
578+
for (let attempt = 0; attempt < 2; attempt += 1) {
579+
const session = this.sessions.get(sessionId)
580+
if (!session) return
581+
582+
const currentMetadata = session.metadata ?? { path: '', host: '' }
583+
const { unreadAt: existing, ...rest } = currentMetadata as { unreadAt?: number | null } & Record<string, unknown>
584+
if (existing == null) {
585+
return
586+
}
587+
588+
const result = this.store.sessions.updateSessionMetadata(
589+
sessionId,
590+
rest as typeof currentMetadata,
591+
session.metadataVersion,
592+
session.namespace,
593+
{ touchUpdatedAt: false }
594+
)
595+
596+
if (result.result === 'success') {
597+
this.refreshSession(sessionId)
598+
return
599+
}
600+
if (result.result === 'error') {
601+
return
602+
}
603+
this.refreshSession(sessionId)
604+
}
605+
}
606+
509607
async deleteSession(sessionId: string): Promise<void> {
510608
const session = this.sessions.get(sessionId)
511609
if (!session) {

hub/src/sync/syncEngine.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,18 @@ export class SyncEngine {
520520
await this.sessionCache.renameSession(sessionId, name)
521521
}
522522

523+
async pinSession(sessionId: string, pinned: boolean): Promise<void> {
524+
await this.sessionCache.pinSession(sessionId, pinned)
525+
}
526+
527+
async markSessionUnread(sessionId: string): Promise<void> {
528+
await this.sessionCache.markSessionUnread(sessionId)
529+
}
530+
531+
async markSessionRead(sessionId: string): Promise<void> {
532+
await this.sessionCache.markSessionRead(sessionId)
533+
}
534+
523535
async deleteSession(sessionId: string): Promise<void> {
524536
await this.sessionCache.deleteSession(sessionId)
525537
}

hub/src/web/routes/sessions.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
DeleteUploadRequestSchema,
33
getPermissionModesForFlavor,
44
isPermissionModeAllowedForFlavor,
5+
PinSessionRequestSchema,
56
RenameSessionRequestSchema,
67
ResumeSessionRequestSchema,
78
SessionCollaborationModeRequestSchema,
@@ -316,6 +317,50 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
316317
return c.json({ ok: true })
317318
})
318319

320+
app.post('/sessions/:id/pin', async (c) => {
321+
const engine = requireSyncEngine(c, getSyncEngine)
322+
if (engine instanceof Response) {
323+
return engine
324+
}
325+
326+
const sessionResult = requireSessionFromParam(c, engine)
327+
if (sessionResult instanceof Response) {
328+
return sessionResult
329+
}
330+
331+
const body = await c.req.json().catch(() => null)
332+
const parsed = PinSessionRequestSchema.safeParse(body)
333+
if (!parsed.success) {
334+
return c.json({ error: 'Invalid body: pinned (boolean) is required' }, 400)
335+
}
336+
337+
try {
338+
await engine.pinSession(sessionResult.sessionId, parsed.data.pinned)
339+
return c.json({ ok: true })
340+
} catch (error) {
341+
const message = error instanceof Error ? error.message : 'Failed to update pin state'
342+
if (message.includes('concurrently') || message.includes('version')) {
343+
return c.json({ error: message }, 409)
344+
}
345+
return c.json({ error: message }, 500)
346+
}
347+
})
348+
349+
app.post('/sessions/:id/mark-read', async (c) => {
350+
const engine = requireSyncEngine(c, getSyncEngine)
351+
if (engine instanceof Response) {
352+
return engine
353+
}
354+
355+
const sessionResult = requireSessionFromParam(c, engine)
356+
if (sessionResult instanceof Response) {
357+
return sessionResult
358+
}
359+
360+
await engine.markSessionRead(sessionResult.sessionId)
361+
return c.json({ ok: true })
362+
})
363+
319364
app.post('/sessions/:id/switch', async (c) => {
320365
const engine = requireSyncEngine(c, getSyncEngine)
321366
if (engine instanceof Response) {

shared/src/apiTypes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ export const RenameSessionRequestSchema = z.object({
131131

132132
export type RenameSessionRequest = z.infer<typeof RenameSessionRequestSchema>
133133

134+
export const PinSessionRequestSchema = z.object({
135+
pinned: z.boolean()
136+
})
137+
138+
export type PinSessionRequest = z.infer<typeof PinSessionRequestSchema>
139+
134140
export const UploadFileRequestSchema = z.object({
135141
filename: z.string().min(1).max(255),
136142
content: z.string().min(1),

shared/src/schemas.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ export const MetadataSchema = z.object({
5454
archiveReason: z.string().optional(),
5555
flavor: z.string().nullish(),
5656
capabilities: SessionCapabilitiesSchema.optional(),
57-
worktree: WorktreeMetadataSchema.optional()
57+
worktree: WorktreeMetadataSchema.optional(),
58+
pinnedAt: z.number().nullish(),
59+
unreadAt: z.number().nullish()
5860
})
5961

6062
export type Metadata = z.infer<typeof MetadataSchema>

shared/src/sessionSummary.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export type SessionSummaryMetadata = {
99
flavor?: string | null
1010
worktree?: WorktreeMetadata
1111
agentSessionId?: string
12+
pinnedAt?: number | null
13+
unreadAt?: number | null
1214
}
1315

1416
export type SessionSummary = {
@@ -41,7 +43,9 @@ export function toSessionSummary(session: Session): SessionSummary {
4143
?? session.metadata.opencodeSessionId
4244
?? session.metadata.cursorSessionId
4345
?? session.metadata.kimiSessionId
44-
?? undefined
46+
?? undefined,
47+
pinnedAt: session.metadata.pinnedAt ?? null,
48+
unreadAt: session.metadata.unreadAt ?? null
4549
} : null
4650

4751
const todoProgress = session.todos?.length ? {

web/build-number.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{ "build": 44 }
1+
{ "build": 48 }

web/src/api/client.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,20 @@ export class ApiClient {
541541
})
542542
}
543543

544+
async pinSession(sessionId: string, pinned: boolean): Promise<void> {
545+
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/pin`, {
546+
method: 'POST',
547+
body: JSON.stringify({ pinned })
548+
})
549+
}
550+
551+
async markSessionRead(sessionId: string): Promise<void> {
552+
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/mark-read`, {
553+
method: 'POST',
554+
body: JSON.stringify({})
555+
})
556+
}
557+
544558
async deleteSession(sessionId: string): Promise<void> {
545559
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
546560
method: 'DELETE'

0 commit comments

Comments
 (0)