feat(sessions): grouping, unread state, and full session controls on web, iOS, and Android#100814
Conversation
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ce0fa2e043
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (entry?.unread == true && !unreadPatchRequested) { | ||
| unreadPatchRequested = true | ||
| _sessions.value = _sessions.value.map { if (it.key == key) it.copy(unread = false) else it } | ||
| scope.launch { patchSession(key = key, unread = false) } |
There was a problem hiding this comment.
Reset the read-ack guard after a confirmed read
When an operator opens an unread session, this path sets unreadPatchRequested = true and never clears it when the sessions list/history later observes unread=false. If another run completes while that same session remains selected, the gateway derives unread=true again (lastActivityAt > lastReadAt), but switchSession does not run and even another call would skip the patch, so the active Android chat can stay marked unread until the user switches away or manually marks it read. Reset the guard when the current row becomes read, or run the same episode guard from session updates.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 3693d7b: acknowledgeUnreadIfNeeded (ChatController.kt) now resets the pending flag when a server-confirmed read (unread=false) is observed and re-acknowledges when the open session flips back to unread — wired into switchSession, event upserts, and full list refreshes. Covered by ChatControllerCommandControlsTest.openSessionReacknowledgesUnreadOncePerEpisode.
| if unread { | ||
| self.acknowledgeChatSessionReadIfNeeded(activeKey) | ||
| } |
There was a problem hiding this comment.
Re-acknowledge the visible chat after new activity
This only acknowledges unread state when openChat is called with unread=true. If the session is already open and a subsequent run completes, the gateway derives unread=true from lastActivityAt > lastReadAt, but no code calls acknowledgeChatSessionReadIfNeeded for the updated current session, leaving the visible iOS chat marked unread until it is reopened or manually marked read. Hook the episode guard to session updates for the active key, as the web client does.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 3693d7b: NodeAppModel.reconcileChatSessionReadState clears the acknowledgement guard when a fresh snapshot confirms the read and re-acknowledges when the focused open session shows unread again; both Command Center session fetch sites feed it. Server-side lastReadAt stamping keeps the exchange convergent.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 529bee65fa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| onSetArchived = { archived -> | ||
| coroutineScope.launch { viewModel.patchChatSession(key = session.key, archived = archived) } | ||
| }, | ||
| onDelete = { deleteSessionKey = session.key }, |
There was a problem hiding this comment.
Reset the active chat after archive/delete
When the row is the current chat, these callbacks only mutate the gateway and refresh the list. If the operator archives the current non-main session, Android leaves chatSessionKey on that archived key, and subsequent chat.send is rejected by the gateway's resolveSessionWorkStartError path for archived sessions; delete has the same stale-active-key problem after the row is removed. Web and iOS reset the active session for these destructive actions, so Android should switch to the main/current fallback after a successful archive/delete of the selected session.
Useful? React with 👍 / 👎.
| label = next.label ?: existing.label, | ||
| category = next.category ?: existing.category, |
There was a problem hiding this comment.
Preserve explicit null clears in session event merges
When another client clears a label or group, the gateway sends label: null / category: null in sessions.changed, but parseSessionEntry converts that to null and this merge treats null as “field absent”. Android therefore keeps showing the old label/category after cross-client clears until a full sessions.list refresh happens. Since grouping and renaming are now multi-client session controls, the event merge needs to distinguish explicit clears from missing fields.
Useful? React with 👍 / 👎.
|
Codex review: stale review; fresh review needed. Summary Next step Review history (2 earlier review cycles)
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f98b764a9b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| try { | ||
| val params = | ||
| buildJsonObject { | ||
| put("key", JsonPrimitive(sessionKey)) |
There was a problem hiding this comment.
Preserve the agent scope for global session controls
When the displayed row key is global for a non-default agent, this request sends only the key. The gateway's sessions.patch contract requires agentId to disambiguate selected-global rows; without it, unscoped global resolves to the default agent, while Android does not retain an owner agent on ChatSessionEntry. As a result, rename/group/pin/unread actions on a non-default global row mutate the default global row or appear to do nothing; thread the row's agent id into these session-control RPCs, or scope the list to one agent.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 324f8112aa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| type="button" | ||
| class="sidebar-session-menu__item sidebar-session-menu__item--destructive" | ||
| role="menuitem" | ||
| ?disabled=${!this.connected} |
There was a problem hiding this comment.
Disable delete for agent main sessions
When this menu is opened for an agent main row such as agent:work:main, the new destructive action stays enabled even though the archive action above uses canArchiveSessionRow to keep main/global rows reachable. The delete RPC only rejects the default main key, so a non-default agent main can be removed and then deleteSession() replaces the UI with that same now-deleted key, leaving the next chat focused on a missing session. Gate Delete with the same main/global/unknown check (or route this to reset) before exposing it here.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e9fca63094
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| SessionMenuItem("Delete…") { | ||
| menuExpanded = false | ||
| onDelete() |
There was a problem hiding this comment.
Gate Android delete for agent main sessions
When the combined session list includes an agent main row such as agent:work:main, this menu still exposes Delete. The gateway only blocks deletion of the default main session, so a non-default agent main row can be removed from Android and the app then falls back to the main alias, leaving the operator focused on a missing anchor session. Apply the same main/global/unknown guard used for non-destructive reachability before offering this action.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a68ab5e23f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const agentId = this.sessionAgentId(key); | ||
| const forkedKey = await context.sessions.create({ | ||
| parentSessionKey: key, | ||
| fork: true, | ||
| ...(agentId ? { agentId } : {}), |
There was a problem hiding this comment.
Fork sessions under their parent agent
When the Sessions page forks a normal non-default agent row such as agent:work:dashboard:123, this.sessionAgentId(key) returns no agent id because it only scopes global/main aliases, so this sessions.create call omits agentId. The gateway then copies the parent transcript but creates the new dashboard session under the default agent, unlike the sidebar/iOS/Android fork paths that parse the parent key's agent, so the fork lands in the wrong agent and uses the wrong agent defaults.
Useful? React with 👍 / 👎.
4875918 to
cc58f9d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cc58f9d045
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ?disabled=${props.loading} | ||
| @click=${(event: MouseEvent) => { | ||
| event.stopPropagation(); | ||
| props.onPatch(row.key, { unread: row.unread !== true }); |
There was a problem hiding this comment.
Scope global unread actions to the row owner
When the Sessions page is listing combined agent stores, loadCombinedSessionStoreForGateway canonicalizes per-agent global rows to the single key global, so the visible row does not carry its owner agent. This new unread toggle passes only row.key; SessionsPage.patchSession then derives agentId from the currently selected agent via sessionAgentId, and sessions.patch resolves an unscoped global to that/default global row. If the displayed global row is from another agent, or the user changes the selected agent after loading, Mark read/unread mutates the wrong global session while the shown row stays unchanged.
Useful? React with 👍 / 👎.
…web, iOS, and Android Gateway (additive, no protocol version bump): SessionEntry gains lastReadAt/markedUnreadAt/lastActivityAt; session rows expose a derived unread flag (explicit mark, or last read before latest activity; never-read sessions stay read so upgrades do not light up). lastActivityAt is stamped in the canonical post-run store update - user, channel, and cron runs count as activity; heartbeat, internal-event, and preserved-state runs do not. sessions.patch gains unread; sessions.create gains fork (transcript fork from parentSessionKey under the parent lifecycle lock, refusing active, concurrently-changed, and oversized parents, cross-agent aware). Web sidebar: Pinned/custom-group/Ungrouped sections, unread dots, kebab and right-click context menu (pin, mark unread/read, rename, fork, move to group, archive, delete guarded for agent main sessions and active runs), mark-read on view with loop-safe re-acknowledgement and failure retry; sessions page gets unread + fork actions and shared custom-group helpers. iOS Command Center: grouped sections, unread/pin indicators, Show Archived gated on per-entry state, full context menu with rename/new-group alerts and delete confirmation, current-session preview guarantee, read-episode re-acknowledgement; new patch/delete/fork transport calls; Swift protocol models regenerated. Android SessionsScreen: grouped headers, unread/pin indicators, Archived filter gated on per-entry state, long-press menu with the full control set, agent-scoped forks, explicit label/category clears from session events, main-session fallback when archiving/deleting the open chat, read-episode re-acknowledgement with failure retry. Closes #100739
cc58f9d to
e0852f0
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e0852f05e3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ), | ||
| }; | ||
| } | ||
| const fork = await forkSessionFromParent({ |
There was a problem hiding this comment.
Preserve user-only transcripts when forking sessions
When an operator forks a completed parent whose transcript has user/system entries but no assistant message (for example a run that failed after the prompt was recorded), this new sessions.create fork path delegates to forkSessionFromParent, whose runtime falls back to writing only a header unless hasAssistantEntry(source.branchEntries) is true. The resulting child session opens with no copied visible messages even though the UI action is presented as a transcript fork; the explicit operator fork path should force the branch entries to be written or otherwise reject this case instead of silently creating an empty child.
Useful? React with 👍 / 👎.
…tion (#100964) sessions.patch becomes a params-aware dynamic-scope method: operator.write now authorizes patches that touch only user-level chat-organization fields (label, category, pinned, archived, unread); every other field, mixed patches, and unknown keys keep requiring operator.admin (fail closed). This unblocks the session controls shipped in #100814 for the Android app, whose bounded operator session intentionally never requests operator.admin. Also: session list ordering gains a deterministic key tiebreaker for equal pinnedAt/updatedAt (stable offset paging and prompt-cache friendliness), a user-assigned label now beats stored channel-derived display names in the row projection so renames survive refreshes, and subagent spawn pins its gateway calls to admin via the params-aware least-privilege resolver instead of the static admin-only method check (#59428 contract preserved). Refs #100712
…web, iOS, and Android (openclaw#100814) Gateway (additive, no protocol version bump): SessionEntry gains lastReadAt/markedUnreadAt/lastActivityAt; session rows expose a derived unread flag (explicit mark, or last read before latest activity; never-read sessions stay read so upgrades do not light up). lastActivityAt is stamped in the canonical post-run store update - user, channel, and cron runs count as activity; heartbeat, internal-event, and preserved-state runs do not. sessions.patch gains unread; sessions.create gains fork (transcript fork from parentSessionKey under the parent lifecycle lock, refusing active, concurrently-changed, and oversized parents, cross-agent aware). Web sidebar: Pinned/custom-group/Ungrouped sections, unread dots, kebab and right-click context menu (pin, mark unread/read, rename, fork, move to group, archive, delete guarded for agent main sessions and active runs), mark-read on view with loop-safe re-acknowledgement and failure retry; sessions page gets unread + fork actions and shared custom-group helpers. iOS Command Center: grouped sections, unread/pin indicators, Show Archived gated on per-entry state, full context menu with rename/new-group alerts and delete confirmation, current-session preview guarantee, read-episode re-acknowledgement; new patch/delete/fork transport calls; Swift protocol models regenerated. Android SessionsScreen: grouped headers, unread/pin indicators, Archived filter gated on per-entry state, long-press menu with the full control set, agent-scoped forks, explicit label/category clears from session events, main-session fallback when archiving/deleting the open chat, read-episode re-acknowledgement with failure retry. Closes openclaw#100739
…tion (openclaw#100964) sessions.patch becomes a params-aware dynamic-scope method: operator.write now authorizes patches that touch only user-level chat-organization fields (label, category, pinned, archived, unread); every other field, mixed patches, and unknown keys keep requiring operator.admin (fail closed). This unblocks the session controls shipped in openclaw#100814 for the Android app, whose bounded operator session intentionally never requests operator.admin. Also: session list ordering gains a deterministic key tiebreaker for equal pinnedAt/updatedAt (stable offset paging and prompt-cache friendliness), a user-assigned label now beats stored channel-derived display names in the row projection so renames survive refreshes, and subagent spawn pins its gateway calls to admin via the params-aware least-privilege resolver instead of the static admin-only method check (openclaw#59428 contract preserved). Refs openclaw#100712
…web, iOS, and Android (openclaw#100814) Gateway (additive, no protocol version bump): SessionEntry gains lastReadAt/markedUnreadAt/lastActivityAt; session rows expose a derived unread flag (explicit mark, or last read before latest activity; never-read sessions stay read so upgrades do not light up). lastActivityAt is stamped in the canonical post-run store update - user, channel, and cron runs count as activity; heartbeat, internal-event, and preserved-state runs do not. sessions.patch gains unread; sessions.create gains fork (transcript fork from parentSessionKey under the parent lifecycle lock, refusing active, concurrently-changed, and oversized parents, cross-agent aware). Web sidebar: Pinned/custom-group/Ungrouped sections, unread dots, kebab and right-click context menu (pin, mark unread/read, rename, fork, move to group, archive, delete guarded for agent main sessions and active runs), mark-read on view with loop-safe re-acknowledgement and failure retry; sessions page gets unread + fork actions and shared custom-group helpers. iOS Command Center: grouped sections, unread/pin indicators, Show Archived gated on per-entry state, full context menu with rename/new-group alerts and delete confirmation, current-session preview guarantee, read-episode re-acknowledgement; new patch/delete/fork transport calls; Swift protocol models regenerated. Android SessionsScreen: grouped headers, unread/pin indicators, Archived filter gated on per-entry state, long-press menu with the full control set, agent-scoped forks, explicit label/category clears from session events, main-session fallback when archiving/deleting the open chat, read-episode re-acknowledgement with failure retry. Closes openclaw#100739
…tion (openclaw#100964) sessions.patch becomes a params-aware dynamic-scope method: operator.write now authorizes patches that touch only user-level chat-organization fields (label, category, pinned, archived, unread); every other field, mixed patches, and unknown keys keep requiring operator.admin (fail closed). This unblocks the session controls shipped in openclaw#100814 for the Android app, whose bounded operator session intentionally never requests operator.admin. Also: session list ordering gains a deterministic key tiebreaker for equal pinnedAt/updatedAt (stable offset paging and prompt-cache friendliness), a user-assigned label now beats stored channel-derived display names in the row projection so renames survive refreshes, and subagent spawn pins its gateway calls to admin via the params-aware least-privilege resolver instead of the static admin-only method check (openclaw#59428 contract preserved). Refs openclaw#100712
…session-row lifecycle, readiness draining) Net-new tsgo:core divergences (vs pre-merge baseline) cleared in 3 clusters: A. infra/sim (fork-only sim harness): upstream renamed packages/llm-runtime -> packages/ai and refactored api-registry into a factory with module-level convenience fns re-exported from @openclaw/ai/internal/runtime. Repointed record-replay/scripted-model-provider/cron-simulation.demo imports; mapped the removed ApiProviderInternal type -> RegisteredApiProvider (getApiProvider's return). Also sourced Api from the ../../llm/types facade + dropped an unused AssistantMessageEvent import in the block being rewritten. B. session-row wire schema: upstream's session-event-payload projection (openclaw#100814 grouping/unread/pin/archive) reads archived/pinned/unread/category/*At off GatewaySessionRow; the merge=ours-adjacent fork schema lacked them. Added the 8 optional lifecycle fields so the adopted projection compiles and emits safe defaults. Full buildGatewaySessionRow<-SessionEntry population deferred. C. server.impl readiness: fork readiness.ts supports getGatewayDraining (reports failing:["gateway-draining"]) but the fork call site never passed it (masked divergence); wired getGatewayDraining: isGatewayDraining and dropped the genuinely-unused PluginRuntime type import. Local tsgo:core: 35 -> 14 unique (remaining 14 are pre-existing fork known-red: exec-policy-cli, grounding/verifier, infra/sim not-exported/unused, deferred-UI). Co-Authored-By: Claude Opus 4.8 <[email protected]>
What Problem This Solves
Session management is uneven across OpenClaw clients (#100739):
Why This Change Was Made
Brings the session list on web, iOS, and Android to parity with modern session managers: grouped sections, unread indicators, and a full per-session control menu, on top of two small additive protocol changes.
Gateway (additive, no protocol version bump):
SessionEntrygainslastReadAt,markedUnreadAt,lastActivityAt(epoch ms), all registered as core-reserved slot keys.unread: explicitly marked unread, or last read before the latest activity (max(lastInteractionAt, lastActivityAt)). Sessions never marked read stayunread: false, so existing installs do not light up on upgrade.lastActivityAtis stamped in the canonical post-run store update (updateSessionStoreAfterAgentRun). Cron runs count as activity; heartbeat and internal-event turns and preserved-state runs do not, so background heartbeats can never re-flag a session unread. Metadata patches never touch it.sessions.patchgainsunread?: boolean(false= record read,true= mark unread; allowed on archived sessions).sessions.creategainsfork?: boolean(requiresparentSessionKey): branches the parent transcript into the new session via the existing fork runtime, under the parent's exclusive lifecycle-mutation lock, refusing active or concurrently-changed parents, with cross-agent transcript placement in the child store andforkedFromParent+ fresh token counters on the child.pnpm protocol:gen:swift).Web (Control UI):
ui/src/lib/sessions/custom-groups.ts) instead of page-local.en.ts; locale bundles regenerated viapnpm ui:i18n:sync.iOS:
OpenClawChatSessionEntrydecodeslabel,category,pinned,archived,unread,lastReadAt,lastActivityAt.patchSession(double-optionalString??for explicit JSON-null label/category clearing),deleteSession,forkSession, andlistSessions(archived:); protocol extension defaults keep otherOpenClawChatTransportconformers source-compatible.commandSessionActions) with the full control set including rename/new-group alerts and a destructive delete confirmation. The 3-row preview orders pinned first and carries the same menu. Opening an unread session acknowledges the read.Android:
ChatSessionEntrycarries the new fields; parsing and partial-event merging preserve them.ChatControllergainspatchSession(explicit JSON null for clears),deleteSession,forkSession(+ archived listing with a request-sequence race guard), exposed throughNodeRuntime/MainViewModel.Out of scope: "Open PR" / "Open in" from the desktop reference UI do not map to OpenClaw sessions.
User Impact
Operators get consistent session organization and triage on all three clients: group sessions into named sections, spot background sessions with new output at a glance (unread dots survive across devices because read state is gateway-owned), and manage any session in place — pin, rename, fork, move between groups, archive, or delete — without visiting the web Sessions page.
Evidence
pnpm test src/gateway/sessions-patch.test.ts src/gateway/session-utils.test.ts src/gateway/server.sessions.create.test.ts src/gateway/server.sessions.list-changed.test.ts src/agents/command/session-store.test.ts— all pass (unread patch transitions incl. archived sessions, table-driven unread derivation, fork transcript-copy/validation/active-parent/cross-agent coverage, heartbeat/preserved-state runs proven not to bumplastActivityAt).pnpm test ui/src/lib/sessions ui/src/pages/sessions ui/src/components— pass (sidebar grouping pure function, unread patch guard incl. re-acknowledge episode semantics, fork wire params, sessions view render contract).pnpm check:changedlanes green on Blacksmith Testbox (typecheck core caught and fixed the reserved-slot-key exhaustiveness guard for the new fields).pnpm build,pnpm ui:i18n:sync,pnpm native:i18n:sync,pnpm protocol:gen:swift,pnpm docs:map:genall clean.docs/reference/session-management-compaction.md(store fields, unread semantics, operator forks),docs/web/control-ui.md(sidebar groups/unread/menu, Sessions page actions).Closes #100739