Skip to content

fix(web): use latest assistant replies for session recency - #1512

Open
techotaku39 wants to merge 23 commits into
tiann:mainfrom
techotaku39:feat/web-last-assistant-reply-time
Open

fix(web): use latest assistant replies for session recency#1512
techotaku39 wants to merge 23 commits into
tiann:mainfrom
techotaku39:feat/web-last-assistant-reply-time

Conversation

@techotaku39

@techotaku39 techotaku39 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Use the latest visible assistant reply as the default session-list recency clock while keeping updatedAt as the activity, unread, and replay clock. This follow-up also makes legacy reply-clock migration asynchronous and bounded, removes redundant fork transcript rescans, and keeps reply-clock updates ordered across dual SSE connections.

Problem / Motivation

The sidebar previously used updatedAt for both activity and displayed recency. Prompt or activity updates could make a session look newer than another session with a more recent AI response, making the right-side timestamp and default ordering difficult to interpret. Legacy transcript backfills also needed to avoid materializing large histories while holding SQLite write locks. Finally, global and session-scoped SSE connections can deliver full session records out of order while a merge or rewind recomputes the reply clock.

Implementation

  • Add nullable last_assistant_message_at in schema v24 and the persisted assistant_reply_clock_backfilled marker in schema v25.
  • Detect visible assistant prose while ignoring tool calls/results, reasoning, hidden progress, and metadata-only events.
  • Maintain the reply timestamp during message ingest, import, and copy operations.
  • Mark merge and rewind transcript edits for asynchronous recomputation instead of decoding the full transcript inside their write transactions.
  • Queue legacy transcript backfills after cache hydration, scanning newest-first in bounded 200-row pages and yielding between pages.
  • Guard backfill completion with the session sequence so concurrent transcript changes leave the marker unset and are retried safely.
  • Emit the complete current session record for exact recomputations, so a reply clock that moves backward or becomes null replaces web caches instead of being clamped by the monotonic structured-patch path.
  • Carry the session sequence as the reply-clock version through structured patches and list summaries; gate full records and versioned backward/null updates on both detail and sidebar caches to prevent stale dual-SSE delivery from restoring an old timestamp.
  • Refresh the live session cache after creating a direct Codex import, because the import inserts transcript rows without per-message realtime events.
  • Stop pending backfill work during SyncEngine shutdown and persist the marker even when no visible reply exists.
  • Aggregate the newest assistant reply during fork batch hydration and update the destination session once, without a second full-transcript scan.
  • Propagate the field through stored sessions, the session cache, SSE patches, API summaries, and web rendering.
  • Sort the default session list by latest assistant reply, falling back to updatedAt when no visible reply exists.
  • Use the same reply/activity fallback for the rendered row, including imported Codex sessions.
  • Preserve global/project pin, active-session, and pending-request priority buckets.
  • Keep explicit order=updatedAt behavior unchanged.
  • Add migration, storage, sync, API, SSE, Codex import, and web regression coverage.

User Impact

  • Sessions with visible AI responses display and sort by their latest reply time.
  • Sessions without visible replies fall back to activity time.
  • Existing databases backfill incrementally after startup instead of blocking hub construction.
  • Rewinds and merges cannot be undone in connected browser caches by an older SSE record.
  • Pinning, active/pending prioritization, unread/activity semantics, and explicit activity-order consumers remain unchanged.

Migration / Rollback

  • Existing databases migrate forward to schema v25.
  • Existing session rows receive an unchecked backfill marker; newly created sessions opt into the current write-through path immediately.
  • The additive fields preserve existing activity timestamps and do not rewrite updatedAt.
  • No destructive down migration is included; if the code is reverted, the additive columns can remain unused under the repository's normal schema policy.

Validation

  • bun test hub/src/store hub/src/sync — 538 passed, 0 failed.
  • bun test hub/src/web/routes/codexDesktop.test.ts — 30 passed, 0 failed; covers live-cache refresh after a newly created direct Codex import.
  • bunx vitest run src/components/SessionList.test.ts src/components/SessionRowSummary.time.test.ts src/hooks/useSSE.test.ts (from web) — 91 passed, 0 failed; includes dual-SSE reply-clock ordering and backward/null update coverage.
  • bun run typecheck:hub — passed.
  • bun run build:hub — passed.
  • Web-mode isolated test site — Hub auth and /api/sessions returned 200; seeded reply-recency data exposed the newer reply before the newer activity-only session.
  • git diff --check — passed.

Related Issues

Fixes #477

AI Disclosure

Tool: OpenAI Codex.
Model: GPT-5.6.
The implementation, diff review, and validation were performed in the HAPI task worktree.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Legacy reply-clock backfill runs synchronously for every session during hub construction, and sessions with no visible reply remain NULL so the full transcript scan repeats after every restart. Evidence: hub/src/sync/sessionCache.ts:166 is reached from reloadAll() at hub/src/sync/sessionCache.ts:247.
    Suggested fix:

    if (!stored.assistantReplyClockBackfilled) {
        this.queueAssistantReplyBackfill(sessionId)
    }
    // The bounded background worker persists both the nullable timestamp
    // and assistant_reply_clock_backfilled = true.
  • [Minor] Fork batch hydration now updates the session row once per assistant message and then decodes the entire destination transcript again. Evidence: hub/src/store/messages.ts:336 and hub/src/store/messages.ts:340.
    Suggested fix:

    let latestReply: { content: unknown; createdAt: number } | null = null
    // During the existing insert loop, retain only the newest assistant reply.
    if (isAssistantTextMessage(message.content)
        && (latestReply === null || createdAt > latestReply.createdAt)) {
        latestReply = { content: message.content, createdAt }
    }
    if (latestReply) {
        touchLastAssistantMessageAt(db, sessionId, latestReply.content, latestReply.createdAt)
    }

Summary

Review mode: initial

Two performance regressions found in startup backfill and fork hydration. No additional correctness, security, or data-loss findings above the confidence threshold.

Testing

Not run (automation/security policy). GitHub test check passed; git diff --check passed.

HAPI Bot

Comment thread hub/src/sync/sessionCache.ts Outdated
&& !this.assistantReplyBackfillAttemptedSessionIds.has(sessionId)) {
this.assistantReplyBackfillAttemptedSessionIds.add(sessionId)
let latestAssistantMessageAt: number | null = null
for (const message of this.store.messages.getAllMessages(sessionId)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Eager backfill blocks hub startup and repeats for no-reply sessions

This is called by reloadAll(), which invokes refreshSession() for every stored session while SyncEngine is being constructed. After the v24 migration every legacy row is NULL, so startup synchronously loads, decompresses, and parses every message in every transcript before the hub is ready. A session with no visible assistant prose stays NULL, so the same full scan repeats on every process restart.

Persist a separate completion marker and process these scans in bounded background batches:

if (!stored.assistantReplyClockBackfilled) {
    this.queueAssistantReplyBackfill(sessionId)
}
// Worker persists last_assistant_message_at (possibly null)
// together with assistant_reply_clock_backfilled = true.

Comment thread hub/src/store/messages.ts Outdated
invoked_at: invokedAt ?? null,
scheduled_at: message.scheduledAt ?? null
})
touchLastAssistantMessageAt(db, sessionId, message.content, createdAt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Keep fork hydration as a true batch operation

This adds one UPDATE sessions for every visible assistant message, then line 340 scans and decodes the entire destination transcript again. Current-session forks can copy an unbounded full history, so the helper no longer has the batch behavior described above it.

Track the newest copied reply during the insert loop and touch the session once; because this path only inserts messages, the final full refresh is unnecessary:

let latestReply: { content: unknown; createdAt: number } | null = null

if (isAssistantTextMessage(message.content)
    && (latestReply === null || createdAt > latestReply.createdAt)) {
    latestReply = { content: message.content, createdAt }
}

if (latestReply) {
    touchLastAssistantMessageAt(db, sessionId, latestReply.content, latestReply.createdAt)
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Reply-clock recomputation still decodes an unbounded full transcript — findLastAssistantMessageAt materializes and decompresses every row, and it is called from merge and rewind while their write transactions are open (hub/src/store/messages.ts:46, related hub/src/store/messages.ts:1014 and hub/src/store/messages.ts:1091). The startup worker has the same per-session issue at hub/src/sync/sessionCache.ts:296: moving one session to each timer tick prevents an all-session startup stall, but one large/no-reply transcript can still freeze REST/SSE and hold SQLite locks.
    Suggested fix:

    // Scan newest-first in bounded pages. For startup, schedule the next page
    // on a later tick; for merge/truncate, mark the clock unbackfilled and let
    // the same worker recompute it outside the write transaction.
    const rows = getAssistantClockPage(sessionId, cursor, 200)
    const reply = rows.find(row =>
        isAssistantTextMessage(decodeMessageContent(row.content))
    )
  • [Minor] Imported Codex sessions without a visible reply display a different clock from the one used to sort them — the list sorter falls back to updatedAt, but this branch still renders importedAt at web/src/components/SessionRowSummary.tsx:92. A newly imported no-reply session can therefore appear near the top while showing an old timestamp, preserving the ambiguity this change is intended to remove.
    Suggested fix:

    return formatRelativeTime(
        session.lastAssistantMessageAt ?? session.updatedAt,
        t
    )

Summary

Review mode: follow-up after new commits

The follow-up removes the per-message copy-batch updates, but reply-clock recomputation remains unbounded per transcript. One additional timestamp consistency issue found. No correctness, security, or data-loss findings above the confidence threshold beyond these items.

Testing

Not run (automation/security policy). Suggested coverage: a large legacy no-reply transcript with bounded/yielding backfill, and an imported Codex no-reply session whose displayed timestamp matches its sort fallback.

HAPI Bot

Comment thread hub/src/store/messages.ts Outdated

function findLastAssistantMessageAt(db: Database, sessionId: string): number | null {
const rows = db.prepare(
'SELECT content, created_at FROM messages WHERE session_id = ?'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Bound transcript scans and keep them out of write transactions

This query materializes every message and then decompresses/decodes every payload. The helper is called twice by mergeSessionMessages while BEGIN is open and once by truncateMessagesFromLocalId inside its transaction; the new startup worker also performs the equivalent unbounded getAllMessages scan. One large or no-reply transcript can therefore block the hub event loop and hold the SQLite write lock even though sessions are scheduled one per timer tick.

Use newest-first bounded pages, yielding between pages in the background worker. For merge/truncate, mark the clock unbackfilled and queue the same recomputation after the transaction:

const rows = getAssistantClockPage(sessionId, cursor, 200)
const reply = rows.find(row =>
    isAssistantTextMessage(decodeMessageContent(row.content))
)
if (!reply && rows.length === 200) {
    scheduleNextPage(sessionId, nextCursor(rows))
}

// Imported Codex sessions used to show the local import time because the
// list only had `updatedAt`. Once a persisted assistant-reply timestamp is
// available, prefer it so imported and native sessions share one meaning.
if (importedAt !== null && session.lastAssistantMessageAt == null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Keep the displayed fallback clock aligned with sorting

When lastAssistantMessageAt is null, default ordering uses updatedAt, but imported Codex rows still display importedAt here. A no-reply import can sort as newly active while showing an old transcript time, so the right-side timestamp no longer explains its position.

Use the same fallback as the sorter:

return formatRelativeTime(
    session.lastAssistantMessageAt ?? session.updatedAt,
    t
)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Authoritative reply-clock recomputations can be discarded by connected web clients — transcript edits intentionally let the backfill write an earlier timestamp or null, but the completion event is emitted as a structured patch at hub/src/sync/sessionCache.ts:347; both detail and list consumers clamp that patch monotonically at web/src/hooks/useSSE.ts:169 and web/src/hooks/useSSE.ts:627. For a long transcript, the messages-invalidated refetch can complete before the paginated scan, after which the authoritative lower/null patch is ignored and the sidebar keeps the deleted reply timestamp and ordering until another full refetch or reload.
    Suggested fix:
    session.lastAssistantMessageAt = refreshed.lastAssistantMessageAt
    this.publisher.emit({
        type: 'session-updated',
        sessionId,
        namespace: session.namespace,
        data: { ...session }
    })
    A versioned reply-clock patch that accepts lower/null values only when its version is newer would also preserve stale-event protection.

Questions

  • None.

Summary

Review mode: follow-up after new commits

The previous unbounded-scan and imported-time findings are no longer present at the latest head. One Major correctness issue remains in propagation of rewind/merge recomputations. No additional security, data-loss, or performance findings above the confidence threshold.

Testing

Not run (automation/security policy). Suggested coverage: cache a session summary/detail, rewind away the latest reply in a transcript spanning multiple yielded pages, let the invalidation fetch happen before backfill completion, and assert the later SSE update moves the clock to the earlier reply or null and re-sorts the list.

HAPI Bot

Comment thread hub/src/sync/sessionCache.ts Outdated
type: 'session-updated',
sessionId,
namespace: session.namespace,
data: { lastAssistantMessageAt: refreshed.lastAssistantMessageAt } satisfies SessionPatch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Propagate authoritative backward recomputations

This backfill result is not always monotonic: rewind/truncation can move the clock to an earlier reply or null. The web patch handlers intentionally apply Math.max and ignore null (web/src/hooks/useSSE.ts:169 and web/src/hooks/useSSE.ts:627), so this structured patch can never correct an already-cached newer value. With a multi-page scan, the invalidation refetch may finish first and leave the stale ordering indefinitely.

Emit an authoritative full-session snapshot for recomputation results, or version the field so newer recomputations may decrease it:

session.lastAssistantMessageAt = refreshed.lastAssistantMessageAt
this.publisher.emit({
    type: 'session-updated',
    sessionId,
    namespace: session.namespace,
    data: { ...session }
})

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Newly created Codex imports leave the reply clock stale in the live cache — the new summary field is read from the cached session at shared/src/sessionSummary.ts:225, but the direct import path creates/caches the session before inserting transcript rows (hub/src/web/routes/codexDesktop.ts:2036, hub/src/web/routes/codexDesktop.ts:2076) and skips message events for newly created sessions (hub/src/web/routes/codexDesktop.ts:2085). The store write updates SQLite only, so the cached value remains null and sorting/display falls back to the final activity timestamp until a later full refresh or hub restart.
    Suggested fix:
    if (engine) {
        engine.recordSessionActivity(sessionId, latestMessageCreatedAt)
        engine.handleRealtimeEvent({ type: 'session-updated', sessionId })
    } else {
        options.store.sessions.touchSessionUpdatedAt(sessionId, latestMessageCreatedAt, options.namespace)
    }

Questions

  • None.

Summary

Review mode: follow-up after new commits

The prior backward-recomputation propagation issue is no longer present at this head. One Major cache-coherency regression remains for newly created direct Codex imports. No additional security, data-loss, or performance findings met the confidence threshold.

Testing

Not run (automation/security policy). Add a regression that creates a Codex import through the live engine, batch-inserts a transcript ending after its latest assistant prose, and asserts both the engine session and GET /api/sessions expose the assistant reply timestamp rather than the activity fallback.

HAPI Bot

thinking: session.thinking,
activeAt: session.activeAt,
updatedAt: session.updatedAt,
lastAssistantMessageAt: session.lastAssistantMessageAt ?? null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Refresh newly created Codex imports after batch insertion

This summary now trusts the cached Session.lastAssistantMessageAt, but the direct Codex import path creates/caches a session before inserting its transcript (hub/src/web/routes/codexDesktop.ts:2036 and hub/src/web/routes/codexDesktop.ts:2076) and skips message events when created is true (hub/src/web/routes/codexDesktop.ts:2085). touchLastAssistantMessageAt updates SQLite only, so the live cache stays null; the new row then displays and sorts by the final activity/tool/user timestamp until a full refresh or restart.

Refresh the session after the batch write:

if (engine) {
    engine.recordSessionActivity(sessionId, latestMessageCreatedAt)
    engine.handleRealtimeEvent({ type: 'session-updated', sessionId })
} else {
    options.store.sessions.touchSessionUpdatedAt(sessionId, latestMessageCreatedAt, options.namespace)
}

Please cover the newly-created import path by asserting the engine cache and GET /api/sessions expose the transcript's latest visible assistant timestamp.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Version backward/null reply-clock replacements — the recomputation emits an unversioned full session record, while the web app processes the same update on independent global and selected-session SSE connections. After rewind/merge, a pre-backfill full record containing the old timestamp can arrive after the corrected full record from the other connection; the full-record branch replaces the cache unconditionally, restoring the deleted timestamp and wrong sidebar order until another refetch. Evidence: hub/src/sync/sessionCache.ts:351, with unconditional replacement at web/src/hooks/useSSE.ts:823 and dual subscriptions at web/src/App.tsx:352 and web/src/App.tsx:364.
    Suggested fix:
    data: {
        lastAssistantMessageAt: refreshed.lastAssistantMessageAt,
        lastAssistantMessageVersion: refreshed.seq
    }
    Carry the version through SessionPatchSchema/SessionSummary, emit it for forward updates too, and apply either a full record or patch only when its reply-clock version is at least the cached version.

Questions

  • None.

Summary

Review mode: follow-up after new commits

The prior newly-created Codex import cache finding is fixed at this head. One Major cross-SSE ordering regression remains for reply-clock recomputations. No additional security, data-loss, performance, or maintainability findings met the confidence threshold.

Testing

Not run (automation/security policy). Add a web regression that delivers the corrected backward/null clock before an older pre-backfill full record on the second SSE path and asserts the higher reply-clock version remains authoritative.

HAPI Bot

// backward or clear it after merge/rewind. Send the complete
// current record so web clients replace their cached summary
// instead of applying the monotonic structured-patch path.
data: { ...session }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Version exact reply-clock replacements

This full record may intentionally lower or clear the clock, but it has no reply-clock version. The app has independent global and selected-session EventSources, and both unconditionally apply full session records. A pre-backfill refresh carrying the old timestamp can therefore arrive after this corrected record on the other connection and restore the stale value.

Suggested fix:

data: {
    lastAssistantMessageAt: refreshed.lastAssistantMessageAt,
    lastAssistantMessageVersion: refreshed.seq
}

Carry that version through the schema/summary, emit it for forward updates, and reject older reply-clock versions for both full records and patches.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

Review mode: follow-up after new commits

No issues found in the latest full diff. The prior Major finding about stale dual-SSE delivery restoring a pre-rewind/pre-backfill reply clock is addressed by carrying the session sequence through reply-clock patches and summaries, then rejecting older full records and patches in both detail and list caches.

Residual risk: the asynchronous legacy backfill and dual-EventSource ordering paths were reviewed statically only; real large-database startup behavior and browser-level cross-connection timing remain integration-sensitive.

Testing

Not run (automation/security policy). Existing added coverage exercises migration/backfill, rewind recomputation, Codex import cache refresh, default list sorting/time rendering, and stale backward/null SSE ordering.

HAPI Bot

@heavygee heavygee added bug Something isn't working area:web Web PWA / React client area:hub Hub server (API, sync, store) community-pr PR from non-collaborator contributor labels Aug 12, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

Review mode: follow-up after new commits

No issues found in the latest full diff. The new head merges current main into the previously reviewed implementation; the production integration remains coherent across message ingest/import/copy, merge and rewind recomputation, REST/session-list ordering, and dual-SSE cache version gates.

Residual risks: large-database asynchronous backfill behavior and browser-level cross-connection event timing remain integration-sensitive and were reviewed statically only.

Testing

Not run (automation/security policy). Existing PR coverage targets migration/backfill, rewind recomputation, direct Codex import cache refresh, list sorting/time rendering, and stale backward/null SSE delivery.

HAPI Bot

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Count visible event/message replies in the reply clock — isAssistantTextMessage falls through to extractAssistantPlainText, which only recognizes codex and output payloads. HAPI also stores sendSessionEvent({ type: 'message' }) as an agent event/message, and the web normalizer renders it as assistant text. Those visible replies therefore never update lastAssistantMessageAt, leaving affected sessions sorted and timestamped by an older reply or the activity fallback. Evidence shared/src/messages.ts:156 (related context: cli/src/api/apiSession.ts:1053, web/src/chat/normalizeAgent.ts:982).

    Suggested fix:

    if (isObject(record.content) && record.content.type === 'event') {
        const data = isObject(record.content.data) ? record.content.data : null
        return data?.type === 'message'
            && typeof data.message === 'string'
            && data.message.trim().length > 0
    }

Questions

  • None.

Summary

Review mode: follow-up after new commits

One Major correctness issue remains in the assistant-reply classifier. No other high-confidence issues found in the latest full diff. Residual risk remains around large-database asynchronous backfill timing and dual-SSE ordering, both reviewed statically only.

Testing

Not run (automation/security policy). GitHub test and integration checks passed. Add a regression test covering a role-wrapped event payload with data.type === 'message' and verify ingest/backfill advances lastAssistantMessageAt.

HAPI Bot

Comment thread shared/src/messages.ts Outdated
if (!isClaudeChatVisibleMessage({ type: data.type, subtype: data.subtype })) return false
}

return Boolean(extractAssistantPlainText(record.content)?.trim())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Count visible event/message replies in the reply clock

This falls through to extractAssistantPlainText, which only recognizes codex and output. However, ApiSessionClient.sendSessionEvent({ type: 'message' }) persists { role: 'agent', content: { type: 'event', data: { type: 'message', message } } } (cli/src/api/apiSession.ts:1053), and the web normalizer renders that envelope as assistant text (web/src/chat/normalizeAgent.ts:982). Those established visible replies never advance lastAssistantMessageAt, so the new ordering/time label remains stale for those paths.

Suggested fix:

if (isObject(record.content) && record.content.type === 'event') {
    const data = isObject(record.content.data) ? record.content.data : null
    return data?.type === 'message'
        && typeof data.message === 'string'
        && data.message.trim().length > 0
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

Review mode: follow-up after new commits

No high-confidence issues found in the latest full diff. The prior Major finding for visible event/message replies is addressed by the new classifier path and regression coverage. Residual risk remains around asynchronous backfill behavior on very large, continuously changing legacy transcripts and real browser delivery ordering across dual SSE connections; these were reviewed statically.

Testing

Not run (automation/security policy). GitHub test and integration checks passed; git diff --check passed.

HAPI Bot

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Extend the strict native patch ports for the reply-clock fields — the hub now emits lastAssistantMessageAt and lastAssistantMessageVersion for visible assistant replies, but Android omits both from its strict key set (android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionPatch.kt:64) and iOS rejects unknown patch keys (ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionPatch.swift:114). Both native stores route the resulting unrecognized event to REST fallback (android/core/data/src/main/kotlin/app/hapi/data/store/SessionStore.kt:384, ios/Packages/HapiKit/Sources/HapiClient/Stores/SessionListStore.swift:261), so reply events can cause repeated list/detail refetches across the dual SSE streams and native caches never receive the new version gate. Evidence shared/src/schemas.ts:381.
    Suggested fix:
    // Android; mirror with PatchField<Int> in Swift, then port apply/sort gates.
    val lastAssistantMessageAt: OptionalField<Long?> = OptionalField.Absent
    val lastAssistantMessageVersion: Long? = null
    
    private val KNOWN_KEYS = setOf(
        // ...
        "lastAssistantMessageAt", "lastAssistantMessageVersion",
    )
    Add the fields to native Session/SessionSummary, apply the same sequence gate, and add a generated SSE fixture for backward/null updates so Android and iOS conformance runs cover this contract change.

Questions

  • None.

Summary

Review mode: follow-up after new commits

One Major protocol-conformance issue remains after the merge from main: the TypeScript wire contract changed without updating the strict native ports.

Testing

Not run (automation/security policy). GitHub drift-gate, integration, and test checks passed. Native conformance checks were not present for this head because no generated SSE fixture changed.

HAPI Bot

Comment thread shared/src/schemas.ts
activeTurnStartedAt: z.number().nullable().optional(),
activeAt: z.number().optional(),
updatedAt: z.number().optional(),
lastAssistantMessageAt: z.number().nullable().optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Keep the strict native patch ports in sync

These two new keys are emitted by SessionCache.recordAssistantMessage for visible replies, but Android does not include them in SessionPatches.KNOWN_KEYS and iOS rejects them via rejectUnknownKeys. Both native session stores therefore classify each reply-clock patch as unrecognized and fall back to REST list/detail refreshes; with global and session SSE streams, this can duplicate those requests. The native caches also never receive the sequence gate that prevents stale clock restoration.

Suggested fix:

// Android; mirror with PatchField<Int> in Swift, then port apply/sort gates.
val lastAssistantMessageAt: OptionalField<Long?> = OptionalField.Absent
val lastAssistantMessageVersion: Long? = null

private val KNOWN_KEYS = setOf(
    // ...
    "lastAssistantMessageAt", "lastAssistantMessageVersion",
)

Also propagate the fields through native Session/SessionSummary and add a generated SSE fixture covering backward/null versioned updates, so both conformance suites exercise the new contract.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Extend the reply-clock sequence gate to REST hydration — the new guards only run for full records received through the SSE handler. A /sessions or /sessions/:id request can snapshot seq N, then a newer SSE correction at seq N+1 can land before the HTTP response completes; the normal query/native refresh path then writes the older response unconditionally and restores the stale timestamp or null. Evidence web/src/hooks/useSSE.ts:82, related writes web/src/hooks/queries/useSession.ts:30, web/src/hooks/queries/useSessions.ts:12, android/core/data/src/main/kotlin/app/hapi/data/store/SessionStore.kt:168, and ios/Packages/HapiKit/Sources/HapiClient/Stores/SessionListStore.swift:119.
    Suggested fix:

    const incoming = await api.getSession(sessionId)
    const current = queryClient.getQueryData<SessionResponse>(key)
    return current?.session && incoming.session.seq < current.session.seq
        ? current
        : incoming

    Apply the same per-row lastAssistantMessageVersion merge to list refreshes and mirror the guard in Android/iOS REST writers.

  • [Minor] Update the native client contract for the new clock and ordering rules — the schema adds reply-clock fields, while the contract still says full sessions always replace cached state and lists sort by updatedAt. Native-client implementers following those docs will miss the new stale-record gate. Evidence shared/src/schemas.ts:381, related stale text docs/api/client-contract/sse.md:159 and docs/api/client-contract/rest.md:33.
    Suggested fix:

    Full Session: accept only when incoming.seq >= cached.seq.
    Reply clock: apply lastAssistantMessageAt using lastAssistantMessageVersion.
    Default list recency: lastAssistantMessageAt ?? updatedAt.

Questions

  • None.

Summary

Review mode: follow-up after new commits

One Major cache-ordering hole remains across REST hydration; the client-contract prose also describes obsolete full-record and sorting behavior.

Testing

Not run (automation/security policy). Add deferred-response tests that start a REST fetch at seq N, apply a seq N+1 backward/null reply-clock event, then resolve the fetch and assert the newer detail and summary remain. Current checks: app-build, integration, package-tests, build, and drift-gate passed; test was pending at review time.

HAPI Bot

Comment thread web/src/hooks/useSSE.ts Outdated
* deliver the same event out of order, so an older record must not replace a
* newer rewind/backfill result.
*/
export function shouldAcceptSessionRecord(current: Session | undefined, incoming: Session): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Apply this sequence gate to REST cache writes too

This protects only full records delivered by the SSE handler. The regular query paths (useSession / useSessions) still let an in-flight HTTP response replace the cache unconditionally; Android loadSessionDetail / refresh and the iOS equivalents do the same. If a request snapshots seq N, a newer backward/null reply-clock correction at seq N+1 can land over SSE, and then the delayed REST response restores the stale clock.

Suggested fix:

const incoming = await api.getSession(sessionId)
const current = queryClient.getQueryData<SessionResponse>(key)
return current?.session && incoming.session.seq < current.session.seq
    ? current
    : incoming

Use the summary watermark for /sessions merges and mirror the guard in both native REST writers.

Comment thread shared/src/schemas.ts
activeTurnStartedAt: z.number().nullable().optional(),
activeAt: z.number().optional(),
updatedAt: z.number().optional(),
lastAssistantMessageAt: z.number().nullable().optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Update the client-contract prose with these new wire rules

docs/api/client-contract/sse.md still says a full Session replaces the cache wholesale, and rest.md still documents default ordering by updatedAt. Those instructions now omit both reply-clock fields and the sequence gate native clients need.

Suggested fix:

Full Session: accept only when incoming.seq >= cached.seq.
Reply clock: apply lastAssistantMessageAt using lastAssistantMessageVersion.
Default list recency: lastAssistantMessageAt ?? updatedAt.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Make the Android full-session gate atomic — the global and session SSE pipes run on separate Dispatchers.Default coroutines, so both handlers can pass the pre-read sequence check and the older record can still write last. Evidence android/core/data/src/main/kotlin/app/hapi/data/store/SessionStore.kt:389.
    Suggested fix:

    _details.update { current ->
        val cached = current[sessionId]
        if (cached != null && full.seq < cached.seq) current
        else current + (sessionId to full)
    }
  • [Minor] Render the reply clock used by native sorting — Android and iOS now order rows by lastAssistantMessageAt ?? updatedAt, but both row views still display updatedAt; a row can therefore appear above another while showing an older timestamp. Evidence android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/SessionSorting.kt:37 and ios/Packages/HapiKit/Sources/HapiProtocol/Models/SessionSorting.swift:35; related rendering context android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt:339 and ios/Hapi/Features/Sessions/SessionListView.swift:250.
    Suggested fix:

    localizedRelativeAge(summary.lastAssistantMessageAt ?: summary.updatedAt)
    formatRelativeAge(
        now: now,
        thenEpochMs: row.summary.lastAssistantMessageAt ?? row.summary.updatedAt
    )

Questions

  • None.

Summary

Review mode: follow-up after new commits

The prior REST-hydration and client-contract findings are no longer present. Two native-client regressions remain: the Android full-record sequence gate is not atomic under the dual-SSE execution model, and native timestamps no longer explain the new row ordering.

Testing

Not run (automation/security policy). Add a concurrent Android test that delivers seq N+1 and seq N full records through separate scopes and asserts N+1 remains, plus Android/iOS row-label tests for the reply/activity fallback. At review time, Android build, fixture drift gate, iOS package/app builds, and integration passed; the main test job was still running.

HAPI Bot

val full = parseFullSession(data)?.takeIf { it.id == sessionId }
if (full != null) {
_details.update { it + (sessionId to full) }
val currentDetail = _details.value[sessionId]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Make this sequence check part of the atomic update

The global pipe and the chat session pipe run in separate Dispatchers.Default scopes. Both handlers can read the same old _details.value, both pass this check, and then the seq N record can write after seq N+1. That leaves the detail cache with the stale reply clock despite the new gate.

Suggested fix:

_details.update { current ->
    val cached = current[sessionId]
    if (cached != null && full.seq < cached.seq) current
    else current + (sessionId to full)
}

return@Comparator right.pendingRequestsCount - left.pendingRequestsCount
}
right.updatedAt.compareTo(left.updatedAt)
val leftRecency = left.lastAssistantMessageAt ?: left.updatedAt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Update the native row labels to use this same clock

This comparator now orders by reply time, but Android still renders summary.updatedAt (SessionListScreen.kt:339), and iOS has the same mismatch (SessionSorting.swift:35 vs SessionListView.swift:250). Users can see a row above another while its displayed timestamp looks older.

Suggested fix:

localizedRelativeAge(summary.lastAssistantMessageAt ?: summary.updatedAt)
formatRelativeAge(
    now: now,
    thenEpochMs: row.summary.lastAssistantMessageAt ?? row.summary.updatedAt
)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Exclude status events and sidechain output from the reply clock — event/message is the status-event family (for example, “Model changed…” and “Compacting conversation.”), while output/assistant can carry isSidechain: true subagent progress. The new classifier accepts both, so these records durably advance lastAssistantMessageAt and reorder every client as though the main agent had replied. Evidence shared/src/messages.ts:106 and shared/src/messages.ts:154; related message-shape context web/scripts/fixtures/cases/events.ts:93 and web/scripts/fixtures/cases/sidechain.ts:98.
    Suggested fix:
    const messageContent = record.content
    if (!isObject(messageContent) || messageContent.type === 'event') return false
    if (messageContent.type === 'output') {
        const data = isObject(messageContent.data) ? messageContent.data : null
        if (!data || data.isSidechain === true || Boolean(data.isMeta) || Boolean(data.isCompactSummary)) return false
    }

Questions

  • None.

Summary

Review mode: follow-up after new commits

The two prior native-client findings are no longer present. One reply-clock classification defect remains: status/subagent activity can be persisted and displayed as the latest assistant reply.

Testing

Not run (automation/security policy). Add classifier and storage/backfill tests proving event/message status rows and output/assistant rows with isSidechain: true do not change lastAssistantMessageAt.

HAPI Bot

Comment thread shared/src/messages.ts Outdated
if (!isObject(content)) return null

if (content.type === 'codex') {
if (content.type === 'codex' || content.type === 'event') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Keep status/subagent activity out of the reply clock

event/message is the status-event family, not an assistant reply—the existing fixtures use it for “Model changed…” and “Compacting conversation.” (web/scripts/fixtures/cases/events.ts:93). The same classifier also accepts output/assistant text with isSidechain: true, which is nested subagent progress (web/scripts/fixtures/cases/sidechain.ts:98). Because message ingest and legacy backfill both call this helper, either shape durably advances lastAssistantMessageAt and reorders every client as if the main agent replied.

Suggested fix:

const messageContent = record.content
if (!isObject(messageContent) || messageContent.type === 'event') return false
if (messageContent.type === 'output') {
    const data = isObject(messageContent.data) ? messageContent.data : null
    if (!data || data.isSidechain === true || Boolean(data.isMeta) || Boolean(data.isCompactSummary)) return false
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Re-fetch after rejecting a stale REST snapshot — the new gates return the entire cached detail/summary when a delayed response has an older reply-clock sequence. That cache may contain only the newer reply patch and still be missing unrelated changes captured by the discarded response. Example: cache at seq 8 / metadata v1; REST captures seq 9 / metadata v2; reply patch seq 10 lands; the seq-9 response is discarded, so metadata v2 remains absent on a quiet session. The same behavior exists in Android and iOS. Evidence web/src/lib/sessionCache.ts:59, web/src/lib/sessionCache.ts:78, android/core/data/src/main/kotlin/app/hapi/data/store/SessionStore.kt:173, android/core/data/src/main/kotlin/app/hapi/data/store/SessionStore.kt:207, ios/Packages/HapiKit/Sources/HapiClient/Stores/SessionListStore.swift:123, and ios/Packages/HapiKit/Sources/HapiClient/Stores/SessionListStore.swift:162.
    Suggested fix:
    const first = await api.getSession(sessionId)
    const current = queryClient.getQueryData<SessionResponse>(queryKeys.session(sessionId))
    if (current?.session && first.session.seq < current.session.seq) {
        // One bounded recovery fetch after the newer SSE watermark.
        const fresh = await api.getSession(sessionId)
        return mergeSessionResponse(current, fresh)
    }
    return first
    Apply the same bounded retry to list refreshes and the native stores.

Questions

  • None.

Summary

Review mode: follow-up after new commits

The prior reply-classification finding is addressed. One cross-client cache-recovery defect remains: a rejected delayed REST response is treated as a successful refresh, allowing unrelated intermediate fields to stay stale indefinitely on a quiet session.

Testing

Not run (automation/security policy). Add Web, Android, and iOS race tests with metadata v2 at seq 9, a reply-clock patch at seq 10, and a delayed seq-9 REST response; verify a bounded follow-up fetch preserves the seq-10 clock and loads metadata v2.

HAPI Bot

incoming: SessionResponse
): SessionResponse {
return current?.session && !shouldAcceptSessionRecord(current.session, incoming.session)
? current

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Re-fetch after rejecting a stale REST snapshot

Returning the entire cached record protects the newer reply clock, but it also treats this REST request as successfully hydrated. The cache may only have received the seq-10 reply patch and still be missing an unrelated seq-9 update contained in this response (for example metadata/title, pin state, or pending-request fields). In that race, the discarded response is the only copy of the intermediate update and the quiet session stays stale. mergeSessionsResponse has the same problem, as do the Android/iOS ports.

Suggested fix:

const first = await api.getSession(sessionId)
const current = queryClient.getQueryData<SessionResponse>(queryKeys.session(sessionId))
if (current?.session && first.session.seq < current.session.seq) {
    const fresh = await api.getSession(sessionId) // one bounded recovery fetch
    return mergeSessionResponse(current, fresh)
}
return first

Apply the same bounded retry to list refreshes and native stores; add a race test where metadata v2/seq 9 precedes a reply patch at seq 10 and the delayed seq-9 response arrives last.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Exclude hidden metadata/tool-only records from the reply clock — isAssistantTextMessage accepts any extracted non-empty text, but the chat hides a footer-only AGENT_NOTIFY_SUMMARY by default (web/src/components/AssistantChat/messages/NotifySummaryText.tsx:130) and renders Inside the task-NNN log… AGY messages as tool-call chips (web/src/chat/normalizeAgent.ts:785). Both therefore reorder/display a session as having a new assistant reply when no assistant prose is visible. Evidence shared/src/messages.ts:170.
    Suggested fix:
    const text = extractAssistantPlainText(record.content)
    if (!text) return false
    const visibleText = stripNotifySummaryFooter(text).trim()
    if (!visibleText) return false
    
    const outputData = isObject(record.content)
        && record.content.type === 'output'
        && isObject(record.content.data)
        ? record.content.data
        : null
    if (outputData?.type === 'agy_message' && /^Inside the task-\d+ log\b/.test(visibleText)) {
        return false
    }
    return true

Questions

  • None.

Summary

Review mode: follow-up after new commits

The prior stale REST-snapshot finding is addressed by bounded retries across Web, Android, and iOS. One reply-classification defect remains: hidden metadata-only and tool-only AGY records can advance the new recency clock.

Testing

Not run (automation/security policy). Add isAssistantTextMessage regressions for a footer-only AGENT_NOTIFY_SUMMARY, prose plus a footer, and Inside the task-266 log...; only the prose-plus-footer case should advance the clock.

HAPI Bot

Comment thread shared/src/messages.ts Outdated
if (!isClaudeChatVisibleMessage({ type: data.type, subtype: data.subtype })) return false
}

return Boolean(extractAssistantPlainText(record.content)?.trim())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Exclude hidden metadata/tool-only records from the reply clock

This returns true for two records that the UI does not render as assistant prose:

  • a footer-only AGENT_NOTIFY_SUMMARY, which NotifySummaryText strips to null by default;
  • AGY Inside the task-NNN log… narration, which normalizeAgent converts to an AgyTaskLog tool-call chip.

Both will update lastAssistantMessageAt and reorder the session even though the latest visible content is metadata/tool activity, contradicting the new clock's contract.

Suggested fix:

const text = extractAssistantPlainText(record.content)
if (!text) return false
const visibleText = stripNotifySummaryFooter(text).trim()
if (!visibleText) return false

const outputData = isObject(record.content)
    && record.content.type === 'output'
    && isObject(record.content.data)
    ? record.content.data
    : null
if (outputData?.type === 'agy_message' && /^Inside the task-\d+ log\b/.test(visibleText)) {
    return false
}
return true

Add classifier tests for footer-only, prose-plus-footer, and Inside the task-266 log... inputs.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

Review mode: follow-up after new commits

No issues found in the latest full diff. The new commit addresses the prior HAPI Bot Major finding by excluding footer-only AGENT_NOTIFY_SUMMARY records and AGY task-log narration from the assistant reply clock, with classifier and storage regressions added. Residual risk is limited to platform-specific compilation and runtime timing paths not exercised in this review.

Testing

Not run (automation/security policy); static review only.

HAPI Bot

…nt-reply-time

# Conflicts:
#	hub/src/store/index.ts
#	hub/src/store/migration-v13.test.ts
#	hub/src/store/migration-v15.test.ts
#	hub/src/store/migration-v18.test.ts
#	hub/src/store/migration-v19.test.ts
#	hub/src/store/migration-v20.test.ts
#	hub/src/store/migration-v23.test.ts
#	hub/src/store/migration-v24.test.ts
#	shared/src/messages.test.ts

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

Review mode: follow-up after new commits

No issues found in the latest full diff. The new head only merges current main; the reply-clock schema migration, assistant-message classification, asynchronous backfill, SSE/REST sequence gates, sorting, and native-client parity remain internally consistent after conflict resolution. Residual risk is limited to platform-specific compilation and long-running backfill timing paths not exercised in this static review.

Testing

Not run (automation/security policy); static review only.

HAPI Bot

…nt-reply-time

# Conflicts:
#	shared/src/sessionSummary.ts

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Shared assistant-text extraction now treats status events as replies — extractAssistantPlainText is used by notification fallback and title generation, so a trailing event/message such as “Model changed…” or “Compacting conversation…” can replace the actual assistant response in notifications and enter title prompts as assistant prose. Evidence: shared/src/messages.ts:150; related consumers hub/src/notifications/nativeNotificationComposer.ts:240, hub/src/sync/titleSuggestion.ts:147.

    Suggested fix:

    if (content.type === 'codex') {
        const data = isObject(content.data) ? content.data : null
        if (!data || data.type !== 'message') return null
        return typeof data.message === 'string' && data.message.trim().length > 0
            ? data.message
            : null
    }

Questions

  • None.

Summary

Review mode: follow-up after new commits

One major regression found: the reply-clock work broadens a shared assistant-text helper in a way that changes notifications and title generation outside the intended sorting behavior. The reply-clock persistence, bounded backfill, sequence-gated backward/null corrections, REST/SSE ordering, and native-client parity were otherwise reviewed statically.

Testing

Not run (automation/security policy); static review only. Add a regression test proving an event/message status after a real assistant reply does not become the notification/title assistant text.

HAPI Bot

Comment thread shared/src/messages.ts Outdated
if (!isObject(content)) return null

if (content.type === 'codex') {
if (content.type === 'codex' || content.type === 'event') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Keep status events out of the shared assistant-text extractor

extractAssistantPlainText is also used by notification fallback and title generation, not only by the new reply-clock classifier. Accepting event/message here means a trailing status event such as “Model changed…” or “Compacting conversation…” can replace the real assistant response in notifications and be fed to title generation as assistant prose. isAssistantTextMessage already rejects event payloads below, so the reply-clock path does not need this widening.

Suggested fix:

if (content.type === 'codex') {
    const data = isObject(content.data) ? content.data : null
    if (!data || data.type !== 'message') return null
    return typeof data.message === 'string' && data.message.trim().length > 0
        ? data.message
        : null
}

If another UI needs event text, add a separate event-specific extractor rather than changing the assistant-text helper’s semantics.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

Review mode: follow-up after new commits

No high-confidence issues found in the latest full diff. The prior Major at shared/src/messages.ts:142 is resolved: event/message status text is no longer accepted by shared assistant-text extraction. Residual risk: the asynchronous reply-clock backfill/retry path has unit coverage for paging, truncation, null results, and version gating, but no sustained-concurrency stress test was found.

Testing

Not run locally (automation/security policy). Current GitHub checks for Test, integration, Android build, iOS package/app, and fixture drift gate passed.

HAPI Bot

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:hub Hub server (API, sync, store) area:web Web PWA / React client bug Something isn't working community-pr PR from non-collaborator contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(web): improve session sidebar with pinning, sorting, and unread indicators

2 participants