feat(desktop): add an archived tasks page to settings - #2985
Conversation
Archived tasks lived in the rail as a filter row, but the work people actually do with them is cleanup — restore or delete several at once, deciding from the project and the date. A 260px navigator cannot host multi-select, so that row could only ever act on one task at a time. This page is that surface: an all/archived scope switch, multi-select with batch restore and delete, and the external task import that also did not belong in a navigator. It does not own a second copy of the catalog. `window.maka.sessions` is the same authority the rail lists from — the rail scopes itself to unarchived tasks, this page can see every task — and mutations here reach the rail through the `sessions.subscribeChanges` broadcast every other writer already uses, so neither surface pushes state at the other. Batch actions run sequentially rather than through Promise.all: each call is a versioned write against the session store, and a failure partway through should leave the tasks it already reached done rather than half-applied across all of them. The rail is untouched here. Removing its Archived and Import rows waits for the rail rebuild, because the row's ⋯ menu is currently the only way to restore an archived task. Refs apache#2984 Generated-by: Claude Code
The row's trailing cluster was a bare `<span>` carrying a class name that does not exist anywhere in the stylesheet, so the project name and the timestamp rendered with no separation at all — "maka-agent6天前". Replaced with Astryx's own HStack and Text, which is what the rest of Settings uses for this shape and which carries the spacing and the muted role without inventing a class for it. Found by running the page in the dev build. Generated-by: Claude Code
…d catalog read model The page listed `sessions.list()` directly, which is the physical session catalog rather than the set of things a person calls a task. Two projections stood between them, both of which the rail already applies: - Edit-and-resend produces one physical session per revision. They are one task and they archive, restore, and delete as one family, so an archived three-version task rendered as three identical rows. - A linked subagent session belongs to the task that spawned it. Listing it here exposed it as a top-level task with its own permanent-delete action, which no other surface offers. `projectTaskRows` now applies both, and the code comment claiming linked children were already excluded is gone: `buildSessionListPredicate` only adds the `subagent_parent_session_id` clause when the filter field is set, so not filtering was never excluding. Collapsing revisions also removes the batch-delete failure it caused. Two rows of one family produced two `remove` calls; the first deleted the whole family and the second aborted the loop with `session_not_found`, reporting a failure for a delete that had fully succeeded. Each task is now isolated so one failure cannot abandon the rest of the selection. Listing moves onto `createSessionListRefresher`, the same single-flight generation-guarded seam the rail reads through, so a burst of change events collapses into one trailing read and a slow response cannot overwrite a newer one. The client-side re-sort is dropped: store order is already recency-first with a stable id tie-break and the projection preserves it. Design changes that follow from naming the page task management rather than task browsing: - The default scope is All rather than Archived, so opening a page called tasks no longer greets you with "nothing archived". - Batch archive joins batch restore, making the two scopes symmetric. Without it the All scope could only delete, which is why it was not the default. - Rows open their task through the `onOpenSession` seam the usage page already uses; the checkbox stops propagation so selecting is not opening. - A skeleton covers first load, and a failed load offers a retry. - Project renames refresh through `projects.subscribeChanges`. Also drops four copy keys left over from a table layout that became a List, and three `revisionFamilySessionIds` results that were computed and never read in the flag, unarchive, and rename row actions. Generated-by: Claude Code
…lection pattern
Three layout faults, all from assembling the page out of raw blocks instead
of the patterns the design system already publishes.
The batch action bar was conditional and lived inside `.settingsActionRow`,
which is `flex-wrap: wrap`. Selecting the first task materialised buttons that
grew the row and pushed the whole list down the page — the list moved under
the cursor that was selecting from it. It is now an always-mounted Astryx
`Toolbar` with `variant="muted"` and a bottom divider, the shape Astryx
documents for contextual bulk actions over a content area. Toolbar has a
stable min-height and cascades its size to the buttons inside it, so the end
slot filling and emptying moves nothing. The start slot always holds
select-all plus a count, which reads as the total until something is selected.
Import sat at the bottom of the page as a lone section wrapping one button,
which rendered as a thin full-width strip. No Astryx page template puts a
primary or secondary action in a footer — actions belong in the header beside
the title, and `LayoutFooter` is for action bars, pagination, and status. It
joins the scope switch in the section header action slot, matching how the
projects page places "Add project", and the page collapses to a single
section: one row of actions, one list.
Rows now select rather than open. A row-level `onClick` around a nested
checkbox is two tab stops for one option, which `ListItem` calls out and
`List` lists as an anti-pattern. `interactiveRef` is the supported form: the
row becomes an enlarged target that delegates to the checkbox it contains, and
opening the task moves to an explicit affordance at the end of the row. This
replaces the `stopPropagation` wrapper, which was working around the conflict
rather than resolving it.
The import dialog kept its `Dialog > Layout{header, content, footer}` frame,
which was already correct, but its content was one flat `VStack` holding eight
conditional blocks — prose, a segmented control, a checkbox, four alerts, a
hand-rolled scrolling div of rows, a load-more button, and a footnote — with no
hierarchy and nothing to say what governed what. Now: the description moves to
`DialogHeader`'s subtitle, alerts collect into one band at the top so the list
below cannot shift as they come and go, the source picker and archived filter
become a muted `Toolbar` over the list they filter, the rows become `List` and
`ListItem`, and the duplicate caveat moves beside the button that would create
the duplicate. A single source no longer renders a one-segment segmented
control, which was never a choice. `LayoutContent` owns the scrolling, so the
hand-rolled max-height list and its row chrome are deleted from
`external-session-import.css`.
Generated-by: Claude Code
…lters The page had an all/archived segmented control, which was a false choice: archived is a subset of all, so the two scopes overlapped and every row then needed an "archived" badge to say which one it was. The rail already navigates active tasks. This page now lists archived tasks only — no scope switch, no badge, and the batch actions stop depending on what happens to be selected. Restore and delete, always both, always meaningful. Import leaves with the scope switch. It produces an active task, so a page that only holds archived ones is the wrong home for it, and beside a filter control it read as one more filter rather than as a way to add to the catalog. The rail keeps its existing import entry, so nothing is lost; where import finally belongs is the rail rebuild's decision. Filters arrive in the same toolbar, following Astryx's toolbar-filter block: a `TextInput` with `hasClear` matching the task name and the project name — both are on screen in a row, so both should answer to the same box — and a ghost `Selector` over the projects that actually hold an archived task. A filter offering choices that all resolve to an empty list is not a filter, so the selector is built from the rows and hides itself when there is nothing to choose between. The end slot of the toolbar swaps rather than grows. With no selection it carries the count and Clear; with a selection it carries the selected count, Restore, and Delete. Clear empties what the filters produced, not the whole archive: the list is the filter's result, so a button that silently reached past the filter would delete tasks the person cannot see. Its confirmation names the count. Empty states now distinguish the two cases they were conflating. Nothing archived at all replaces the page; a filter that matched nothing replaces only the list, so the filters that produced it stay on screen to be undone. `filterArchivedTasks` is separate from `archivedTaskRows` because they answer different questions — what a task is, versus which of them you are currently looking at — and only one of them changes as you type. Six tests cover the new one. Generated-by: Claude Code
…ty-list carrier The page was the only one in settings speaking its own visual language: a bare Astryx Table with a header row, column rules and a checkbox column, inside a surface where every other page — projects, the permission centre, the provider catalog, the provider panel, Runtime Host, about — renders a SettingsSection over a List/ListItem group. That mismatch, not any single component choice, is what made it read as foreign. It now uses that carrier. Each row carries the task name over "project · last activity", with 恢复 in the open and 打开 / 彻底删除 behind the row menu; one destructive 清空全部 sits beside the search box and clears every archived task rather than whatever the box happens to match. Multi-select is gone with the Table. The middle case it served — restore five of eleven — is rare enough that it did not pay for a checkbox column, a select-all cell, and a batch strip that pushed the list down the moment a row was ticked. Search moves into the catalog module as `matchesArchivedTaskQuery` so it is covered by tests rather than living inline in the component. Adds a Product/Settings/Pages ArchivedTasks story on a sessions + projects bridge, so this page has the same Storybook ground truth as its neighbours. Generated-by: Claude Code
08ffdb2 to
e53fd17
Compare
…alog The page listed sessions itself and carried its own copy of restore and delete. Both were parallel implementations of authorities that already existed, and both had drifted from them. Rows now come from `deriveSessionRail`, the projection the rail already uses, instead of a second one. The two disagreed on linked subagent sessions: deleting an archived parent does not cascade to an ordinary subagent child, and the rail keeps the orphan as a row of its own while this page dropped every linked child unconditionally. A task could therefore outlive the only surface that claimed to clear it, with 清空全部 reporting success. The rebuilt story reproduces exactly that case. Restoring and deleting a single task now call the rail's own row actions, so there is one confirm, one cleanup and one set of toasts per operation rather than two that could diverge. A sweep is the one thing single-row delete cannot phrase, so `purgeSessions` joins them at the same owner. It settles the outcome against the refreshed catalog rather than against the rejections, because the delete IPC commits the removal before it releases renderer resources — a rejection there never meant the task survived. Also: - `tasks` becomes `archived-tasks`. The id is a public `maka://settings/` deep link, and the command palette showed it as 任务 while the settings nav showed 已归档任务. - While a search is on screen the button deletes what is on screen, and the confirm says so. It re-reads its target set after the confirm, so a task restored while the dialog was up is not deleted by a stale snapshot. - `openSessionInChat` lands on the filter that lists the session. It was pinned to `chats`, which excludes archived ones, so search, the command palette and this page could all open a session with no rail row. - A row that cannot resolve its project says nothing instead of claiming 无项目, which was a false statement whenever the project lookup failed. - Each row's 恢复 button gets its own accessible name; the search box never matches across the seam between a task's name and its project. - Drops the unrelated external-session-import dialog rework, which was left over from an earlier iteration of this page and belongs in its own change. Generated-by: Claude Code
A second adversarial review of the page found four defects in the sweep itself, three of them introduced by the rebuild. `purge` re-read its target set after the confirm resolved. That was meant to spare a task someone restored while the dialog was up, but the same mechanism let the set grow: another client archiving a task during the dialog added it, so a confirm that named three tasks could delete four, permanently. The set is now frozen at the click, and shrinking happens at the other end — `purgeSessions` skips anything no longer archived. It also skips ids with a row action already in flight, which it previously walked straight past, so a sweep and a rail delete could both fire at the same id. `purgeSessions` judged the outcome by re-listing the catalog, on the grounds that a rejection does not prove a task survived. The premise holds; the implementation did not. `refreshSessions` swallows a listing failure and answers with the pre-delete list, so a sweep that removed everything would report every task as still there and skip every family's renderer cleanup. Now only the rejected ids are checked back, against a listing whose failure is visible, and when the catalog cannot be read the outcome says so rather than claiming a result. The first rejection travels with it: a task refuses to retire while its turn is still running, and "N still there" gave the reader nothing to act on. `archivedTaskRows` no longer takes the open session id. The rail passes one so it can highlight a row; it also pins a family's representative to whichever revision is open, which moved a row's name, date and position here for a reason this page never shows. `openSessionInChat` now picks the filter from the target's rail row rather than from the target. A linked child has no row of its own, so an archived one under a live parent sent the rail to `archived`, where neither appears. Also: - The four projection tests become one. A single fixture — a revision family, a linked child under a listed parent, an orphaned one, and an active task, in an order that is deliberately not recency order — kills every mutant the four killed plus the reordering mutant all four missed, in a third of the lines. - `.filter(Boolean)` dropped from the search haystack: `join` already renders a missing project as nothing, so no input could tell them apart. - The story confirms before deleting a row, as the shell's row action does, instead of dropping it on one click; every story now gets a working archived-tasks bridge instead of a shared empty constant. - 取消 comes from the shared settings copy rather than a third copy of it. Generated-by: Claude Code
The sweep's semantics only lived in comments: it deletes whole revision families, leaves anything that stopped being archived after the confirm, skips ids whose row action is already in flight, and settles rejections against a fresh catalog listing because the delete IPC commits before it releases renderer resources — a rejection is not evidence the task survived. When that listing cannot be read it reports nothing rather than trusting the refresher, which answers with the stale pre-delete list on failure. Reuses the injectable deps and the window stub the revision row-action test already established. Every branch above is mutation-checked. Generated-by: Claude Code
The rail had an Archived filter row, so adding the Settings page gave the same tasks two homes and two sets of actions. This removes the filter: the rail lists what you are working on, and Settings › 活动 › 已归档任务 is where archived tasks are found, restored, or deleted. Search now skips archived tasks, matching the command palette, which already did. Two surfaces for finding a task disagreed about archived ones, and a hit that can only be opened from Settings is not a chat hit; archived tasks also stop consuming the 200-session scan budget they shared with active ones. Removed with it: the SessionFilter 'archived' value, the rail row and its copy, the nav-filter branch, the stored-selection validator branch (a stored 'archived' now falls back to 'chats', which is the migration), the rail-derived filter choice in openSessionInChat, and the settings page's Open action — an archived task has no rail row to land on, and giving it one would make "the open task is always visible in the rail" an invariant the rail does not otherwise hold. Generated-by: Claude Code
M4n5ter
left a comment
There was a problem hiding this comment.
English
Thanks for the thoughtful implementation. The overall direction looks sound, and I found no blocking issues. I did confirm one important correctness gap in the bulk purge path:
purgeSessions() snapshots the archived IDs once before processing the list. If another client restores a later task while the serial sweep is running, that task remains in the snapshot and can still be permanently deleted.
I reproduced this deterministically by restoring the second task after the first removal completed; both tasks were still passed to remove, and the outcome reported both as removed.
The trigger requires concurrent activity, so I would not classify it as blocking. However, because this is a permanent-deletion boundary and contradicts the intended “skip tasks no longer archived” behavior, I recommend addressing it here. Ideally, the Runtime Host should atomically require the task to remain archived when removing it; rechecking only the Renderer snapshot would still leave a TOCTOU window.
Everything else I reviewed—the task projection, revision-family behavior, orphan accessibility, navigation migration, and search exclusion—looks consistent with this PR’s stated intent.
中文
感谢这次细致的实现。整体方向合理,我没有发现阻塞性问题,但确认了批量清理路径中的一个重要 correctness 缺口:
purgeSessions() 在开始处理前只对归档 ID 做了一次快照。如果串行清理期间另一个客户端恢复了后面的某个任务,该任务仍存在于旧快照中,之后仍可能被永久删除。
我做了确定性复现:第一个任务删除完成后恢复第二个任务,最终两个任务仍都进入了 remove,结果也将两者都报告为已删除。
该问题需要并发操作才能触发,因此我不把它列为 blocking。不过它处于永久删除边界,并且违背了“跳过已经不再归档的任务”这一预期行为,建议在本 PR 中处理。理想方案是由 Runtime Host 在删除时原子校验任务仍处于归档状态;仅重新读取 Renderer 快照仍会留下 TOCTOU 窗口。
除此之外,我检查的任务投影、revision family 行为、orphan 可达性、导航迁移和搜索排除,都与本 PR 的目标一致。
purgeSessions snapshotted the archived ids once before the loop, so a task restored from another window while the serial sweep ran was still deleted permanently and reported as removed. The bridge's contract already said the sweep touches tasks 'still archived when it reaches them'; the code said 'archived when it started'. Reading the catalog per task is also less code than the snapshot it replaces. This narrows the window rather than closing it: nothing on this side can stop a restore that lands between the check and the removal. Only the Host, which owns the lifecycle, can require the task to still be archived as it removes it — tracked separately. Found by M4n5ter reviewing apache#2985. Generated-by: Claude Code
|
Confirmed and fixed in The sweep now reads the catalog as it reaches each task rather than trusting one taken at the start. That also matches what the bridge's contract already claimed ("still archived when the sweep reaches them") and removes more code than it adds. A regression test reproduces your scenario — the second task is restored after the first removal completes, and it survives — and I mutation-checked it by reinstating the snapshot, which fails the test. The atomic guarantee belongs to the Host, as you say. I left it out of this PR: |
The dot the rail draws for a session passed through two lossy hops and three disagreeing sources, so it could not say what it meant. Collapse the mapping. `SessionStatus -> SessionStatusTone -> StatusDotVariant` becomes one table from status to Astryx's variant. Two of the seven tones had no distinct outcome at the end of that chain, and the collapse is what let `waiting_for_user` and `blocked` share `warning`: blocked now maps to `error`, so "cannot proceed until you fix a connection" and "holding a question for you" stop rendering alike. Read the running authority. `runningTurnIds` is the runtime's projection of the runs it holds; `session.ts` states why a persisted `status` cannot serve that purpose, and `settledSessionTransientIds` already reads it first over the same list. The row read neither -- a renderer-local streaming set, then the stored `status` -- so a task running under a bot channel or a second window read as idle. The streaming set stays below it, for the gap between this renderer sending a turn and the host reporting it back. Delete what nothing wrote. `review` and `done` were never written by anything, in any version, so no stored record can carry them and no reader of them was reachable; they leave `SESSION_STATUSES`, the wire enum, and the copy tables. `SessionLifecycleStatus` now aliases `SessionStatus` instead of restating it -- three hand-written copies of one enum is how those two values survived in two of them after the last writer went away. Delete the tautological filter. `SessionFilter` had one value left: `archived` moved to Settings in apache#2985 and `flagged` was never selected, which left a control whose answer is always the same answer. The 「会话」 row that selected it goes with it, as does the branch in `sessionMatchesNavSelection`. A stored filter is dropped rather than validated, which is the migration. Also gone: `SessionStatusPresentation.interactive`, which had no reader, and a doc comment naming a `SessionStatusIcon` and a chat-header badge as the tone matrix's consumers -- neither exists. `status: 'archived'` still duplicates `isArchived`. Consolidating those two rewrites stored rows, so it is apache#2984's PR 3, not this one. Refs apache#2984 Generated-by: Claude Code
Deleting the import dialog left exact-string readers behind. `scripts/ check-astryx-alignment.mjs` read the dialog and its stylesheet by path, so running it exited 1 with ENOENT; the surface inventory still listed both files and had no row for the page that replaced them. The dialog's Item-row guards are not migrated, they are dropped. They checked that a SELECTED row stayed keyboard-reachable -- no parent role stealing Item's native button, no selected-only tabIndex trapping focus. 设置 › 活动 › 导入任务 has no selection: 导入 sits on each row the way 恢复 does on the archived page. Asserting those smells against the new page would guard a shape it does not have. The button guard and a ListItem import check follow the surface to the page. Inventory rows for the new page and for apache#2985's archived-tasks page, which was never added. Three runtime-host entries stay missing; they predate this branch. Also read the Daily Review model label from its copy table instead of a literal. The story matched '跟随对话默认', which the rename retired, so it silently found nothing -- and `storybook-visual-smoke.mjs` disables every `play` function, so CI could not report it. Refs apache#2984 Generated-by: Claude Code
|
Confirmed, and tracked as #3050. Two corrections to the diagnosis, and both make the fix smaller than the one recommended. The snapshot is already gone. The merged version re-reads the catalog as the sweep reaches each task rather than trusting one snapshot taken at the start. That narrows the window; your actual point — that re-reading on the Renderer side cannot close it — stands, and it is why the issue exists. The Host does already require it atomically — through revision, not lifecycle. The defect is the Desktop client's retry loop. That makes an expected-lifecycle field on The fix therefore lands in Disclosure: drafted with Claude Code, which traced the code paths cited above. The reasoning here comes from reading source — 中文问题确认,已用 #3050 跟踪。对诊断有两点修正,两点都让修复范围比建议的更小。 快照那部分已经不存在了。 合并版是在清理推进到每个任务时重读目录,而不是信任开始时的一次快照。这收窄了窗口;你真正的论点——渲染进程侧重读无法关闭这个窗口——成立,这也是 issue 存在的原因。 Host 其实已经原子地要求了,只是靠 revision 而不是 lifecycle。 缺陷在桌面端客户端的重试循环。 这样一来, 因此修复落在 披露:本回复由 Claude Code 起草,上述代码路径由它追溯。结论来自阅读源码( |
The dot the rail draws for a session passed through two lossy hops and three disagreeing sources, so it could not say what it meant. Collapse the mapping. `SessionStatus -> SessionStatusTone -> StatusDotVariant` becomes one table from status to Astryx's variant. Two of the seven tones had no distinct outcome at the end of that chain, and the collapse is what let `waiting_for_user` and `blocked` share `warning`: blocked now maps to `error`, so "cannot proceed until you fix a connection" and "holding a question for you" stop rendering alike. Read the running authority. `runningTurnIds` is the runtime's projection of the runs it holds; `session.ts` states why a persisted `status` cannot serve that purpose, and `settledSessionTransientIds` already reads it first over the same list. The row read neither -- a renderer-local streaming set, then the stored `status` -- so a task running under a bot channel or a second window read as idle. The streaming set stays below it, for the gap between this renderer sending a turn and the host reporting it back. Delete what nothing wrote. `review` and `done` were never written by anything, in any version, so no stored record can carry them and no reader of them was reachable; they leave `SESSION_STATUSES`, the wire enum, and the copy tables. `SessionLifecycleStatus` now aliases `SessionStatus` instead of restating it -- three hand-written copies of one enum is how those two values survived in two of them after the last writer went away. Delete the tautological filter. `SessionFilter` had one value left: `archived` moved to Settings in apache#2985 and `flagged` was never selected, which left a control whose answer is always the same answer. The 「会话」 row that selected it goes with it, as does the branch in `sessionMatchesNavSelection`. A stored filter is dropped rather than validated, which is the migration. Also gone: `SessionStatusPresentation.interactive`, which had no reader, and a doc comment naming a `SessionStatusIcon` and a chat-header badge as the tone matrix's consumers -- neither exists. `status: 'archived'` still duplicates `isArchived`. Consolidating those two rewrites stored rows, so it is apache#2984's PR 3, not this one. Refs apache#2984 Generated-by: Claude Code
Deleting the import dialog left exact-string readers behind. `scripts/ check-astryx-alignment.mjs` read the dialog and its stylesheet by path, so running it exited 1 with ENOENT; the surface inventory still listed both files and had no row for the page that replaced them. The dialog's Item-row guards are not migrated, they are dropped. They checked that a SELECTED row stayed keyboard-reachable -- no parent role stealing Item's native button, no selected-only tabIndex trapping focus. 设置 › 活动 › 导入任务 has no selection: 导入 sits on each row the way 恢复 does on the archived page. Asserting those smells against the new page would guard a shape it does not have. The button guard and a ListItem import check follow the surface to the page. Inventory rows for the new page and for apache#2985's archived-tasks page, which was never added. Three runtime-host entries stay missing; they predate this branch. Also read the Daily Review model label from its copy table instead of a literal. The story matched '跟随对话默认', which the rename retired, so it silently found nothing -- and `storybook-visual-smoke.mjs` disables every `play` function, so CI could not report it. Refs apache#2984 Generated-by: Claude Code
…ary (#3033) * fix(ui): give the task rail's status dot one meaning The dot the rail draws for a session passed through two lossy hops and three disagreeing sources, so it could not say what it meant. Collapse the mapping. `SessionStatus -> SessionStatusTone -> StatusDotVariant` becomes one table from status to Astryx's variant. Two of the seven tones had no distinct outcome at the end of that chain, and the collapse is what let `waiting_for_user` and `blocked` share `warning`: blocked now maps to `error`, so "cannot proceed until you fix a connection" and "holding a question for you" stop rendering alike. Read the running authority. `runningTurnIds` is the runtime's projection of the runs it holds; `session.ts` states why a persisted `status` cannot serve that purpose, and `settledSessionTransientIds` already reads it first over the same list. The row read neither -- a renderer-local streaming set, then the stored `status` -- so a task running under a bot channel or a second window read as idle. The streaming set stays below it, for the gap between this renderer sending a turn and the host reporting it back. Delete what nothing wrote. `review` and `done` were never written by anything, in any version, so no stored record can carry them and no reader of them was reachable; they leave `SESSION_STATUSES`, the wire enum, and the copy tables. `SessionLifecycleStatus` now aliases `SessionStatus` instead of restating it -- three hand-written copies of one enum is how those two values survived in two of them after the last writer went away. Delete the tautological filter. `SessionFilter` had one value left: `archived` moved to Settings in #2985 and `flagged` was never selected, which left a control whose answer is always the same answer. The 「会话」 row that selected it goes with it, as does the branch in `sessionMatchesNavSelection`. A stored filter is dropped rather than validated, which is the migration. Also gone: `SessionStatusPresentation.interactive`, which had no reader, and a doc comment naming a `SessionStatusIcon` and a chat-header badge as the tone matrix's consumers -- neither exists. `status: 'archived'` still duplicates `isArchived`. Consolidating those two rewrites stored rows, so it is #2984's PR 3, not this one. Refs #2984 Generated-by: Claude Code * refactor(ui): rebuild the task row on two signal slots Every task row in the rail now carries exactly two signal slots. Slot 1 is a single status dot, reserved as an 8px gutter even when there is nothing to show so titles stay aligned down the list. Slot 2 holds a compact timestamp at rest and swaps to the ⋯ menu on hover or keyboard focus, so the menu is no longer permanently mounted next to every title. The dot's meaning is resolved once, in priority order: live run (accent, pulsing) › persisted status › unread (accent, inactive rows only). The live run reads `runningTurnIds` first, matching `settledSessionTransientIds` over the same session list — a persisted `running` status can be left behind by a crash, so it must not outrank the authoritative projection. The stale pill is gone: it duplicated the timestamp it sat next to. The worktree mark falls back to the row tooltip rather than competing for a slot. `RelativeTime` gains `variant="compact"` instead of a second component; a 260px rail row cannot fit the medium-date-plus-time past-horizon fallback. Generated-by: Claude Code * refactor(ui): give the rail one top region and one grouping switch The rail's top region now ends with the grouping switch, and the history below it starts directly at its first group. The list heading is gone. "会话" labelled a list that is the only thing under it, inside a landmark that already names the panel — and it existed mainly to give the grouping switch somewhere to sit. The switch moves into the sticky top region instead, where it stays reachable: hung off a heading it scrolled away with the very list it switches. It is also full width with text labels now. Two icon-only segments asked the rail to teach a clock and a folder, and the rail has nowhere to teach them; 按时间 / 按项目 is the entire vocabulary and fits inside the minimum 180px width with room to spare. Collapsed at 48px it does not render at all — no segment label fits, and the list it governs is not rendered either. The group headers lose their ➕. It was the 新任务 row's handler under a second name, three rows below the row itself, and it needed that second name precisely because two controls cannot share one accessible name. Generated-by: Claude Code * feat(desktop): move task import out of the rail into settings 导入 was a rail row that opened a modal over the conversation. Import is not navigation: it is a rare setup errand, it needs a source, an archived filter and a paged directory of someone else's conversations to work through, and none of that belongs in a 260px column of the tasks you are working on. It becomes 设置 › 活动 › 导入任务, next to 已归档任务 — both are about the task catalog rather than the task in front of you. As a page the flow loses its selection state. A dialog needed one because its single primary action sat in a footer far from the rows; a page puts 导入 on the row itself, the way 已归档任务 puts 恢复 on its rows. That also retires `ExternalSessionImportLifecycle`, whose whole job was refusing to close a dialog mid-import — there is no dialog left to hold open, and an import that outlives the page is guarded by the mounted ref instead, so it can still finish but can no longer steer a shell the user has navigated away from. The source switch now appears only when there is more than one source. Codex is the only adapter that ships, so in practice every user was being shown a segmented control with a single segment — a control nobody can operate. With one source the section names it instead. Deliberately not carried over: keeping an imported task in sync with its source. The importer is a one-shot conversion and nothing behind it watches the source, so that control would be a promise no coordinator can keep. Generated-by: Claude Code * refactor: name the thing a user works on 任务 / task everywhere The product called the same object 会话, 对话, session and conversation depending on which file you landed in. Code, protocol and storage keep `session` — that is a durable contract with a wire format behind it — but every string a user reads now says 任务 / task. The rule is what the noun REFERS TO, not the word: - The Maka entity a user opens, renames, archives and searches → 任务 / task. - Conversing as a behavior stays 对话 / conversation: "与 Maka 对话" over a bot, "可用于对话的模型", "对话记录" as a message log. - Another agent's stored conversations stay 对话 / conversation. 导入任务 lists Codex's conversations and produces Maka tasks; collapsing both onto one word would erase exactly the distinction that page is about. - 侧边对话 stays: it is a chat beside a task, not a task in the rail. Two collisions had to be resolved rather than renamed. The per-task ledger was 会话任务, which would have become 任务任务 — its items are 待办 / to-dos, which is what they always were. And the automations row read "定时任务,N 个 未完成任务", two different meanings of 任务 in one label; the count now says "N 条进行中". The keyboard sheet loses its ←/→ row. It documented cycling between 会话/已标记/已归档, a filter this PR removed along with `SessionFilter`. Generated-by: Claude Code * test(ui): make the narrow-rail story actually narrow `LongTitlesAndNarrow` framed the panel at 176px but let SideNav keep its own 260px `resizable` width, so the story clipped a wide rail instead of showing a narrow one — the timestamps it exists to check were outside the frame. It now drives the rail's own width, at 180px, which is the panel's `minWidth` and therefore the narrowest state a user can reach. Generated-by: Claude Code * fix(ui): keep the rail's 任务 row as the way back to the list bb39b33 deleted `SessionFilter` and took the 「会话」 row with it, on the reading that the row only selected a filter whose answer was always the same answer. Half right: its `isSelected` read the dead filter, but its `onClick` selected the SECTION, and it was the only control that did. Without it the rail has three sections and two rows. Collapsed at 48px — the default state (`readSessionListCollapsed`) — the list is not rendered at all, so 扩展 and 定时任务 became one-way doors: the only way back to a running task was 新任务, which answers "show me my tasks" by starting another one. `streaming-remount.spec.ts` walks exactly that path and had been rewritten to click a task row that a collapsed rail does not have. Restore the row as 任务, selected on `section === 'sessions'` and selecting the section with no filter. The filter deletion stands. Refs #2984 Generated-by: Claude Code * fix(ui): route task status through the shared semantic layer Deleting `SessionStatusTone` was right; replacing it with a private `SessionStatus -> StatusDotVariant` table was not. `status-vocabulary.ts` already owns "the one place a status word becomes a colour", so the private table made a second authority, and the two disagreed: a task waiting on a permission prompt drew `error` in the rail while the permission centre drew `attention` for the identical condition. Map the session enum to `StatusSemantic` and let `dotForStatus` pick the colour. `blocked` and `waiting_for_user` are both `attention`, which is what that semantic is defined as -- both are waiting on a person. Giving `blocked` `error` to tell them apart used colour for a distinction colour cannot carry; their labels and `describeBlockedReason` do that. Restore `review` and `done`. They have no writer in current source, but `SESSION_STATUSES` is read back out of storage, and narrowing it is a data migration rather than a cleanup: `resolveLegacyStatus` in the JSONL importer (removed in #2656) passed both values through into real SQLite stores verbatim, and `normalizeSessionHeader` throws for the WHOLE header on an unrecognised status, so one stored row carrying `done` fails an entire catalog page. The migration is its own change. `archived` and `aborted` get their dots back. They were `muted` before, `muted` resolved to a real `neutral` dot, and dropping them to `undefined` was a behaviour change I described as a consequence of collapsing the layer. Refs #2984 Generated-by: Claude Code * fix(ui): keep the task row's facts when it collapses to two slots The row draws one dot and one trailing slot. That was implemented by collapsing to a single signal inside the resolver, which made the visual element the only carrier of the fact -- so removing the trailing `Badge` removed "stale" itself, not just its pill. `staleTitle` and `staleAriaLabel` were left with no reader at all, the surviving `opacity` cue is cancelled on the selected row by design, and opacity announces nothing. Worktree went the same way: an `aria-label`ed icon inside the button became a `title` on a non-interactive ancestor, which contributes to neither the button's name nor its description. Resolve a LIST of signals in priority order, draw `signals[0]`, and put the rest -- plus worktree and the absolute timestamp -- in one visually hidden span inside the button. Two slots, same pixels, and the facts stop depending on which of them had room. Signals also stop eating each other: `aborted` now keeps its own dot instead of falling through to the unread branch, where an aborted task with unread text drew the same accent dot as a running one. Stale joins the list as `attention`, so a stale task finally has a signal that survives being selected. It is resolved here rather than in `presentSessionStatus` because it is a renderer-derived fact, not a persisted `SessionStatus`. Drop the `runningTurnIds` read. No `SessionSummary` reaching Desktop carries that field: Runtime Host's catalog reads storage directly, the wire projection does not list it, and Desktop Main's converter copies persisted fields only -- the identifier appears nowhere in `packages/runtime-host/src` or `apps/desktop/src/main` outside a test fixture. It was a read of something nothing populates, described as reading the authority. `streaming` is what the rail actually has, with the limit that it only knows this renderer's turns; a real live-run projection from the Host is its own change. Also: `pointer-events: none` on the resting ⋯, which sat invisible at `z-index: 1` over the trailing slot and swallowed clicks; and the inner list no longer repeats the rail's own accessible name. Refs #2984 Generated-by: Claude Code * fix(desktop): refresh the catalog when an import's outcome is unknown `external-sessions:import` emits `sessions:changed` on success, so a task imported while the user walks away from Settings still reaches the rail on its own -- the comment in `app-shell.tsx` claiming nothing notifies the shell was wrong, and is corrected here. The gap is the other branch. `commit_outcome_unknown` means the Host cannot say whether the task was committed, and that path returned a result without emitting anything. The only trace was the page's own banner, and 导入任务 is a Settings page: leaving it unmounts the banner, which is exactly when someone comes back and imports the same conversation again. Emit there too, with no id, because not knowing which task landed is what the code means. Also return the import promise from `clickAction` instead of `void`-ing it. Astryx's Button awaits it and drops repeat clicks until it settles; discarding it left double-submit to `importingId` alone, one render behind the second click. Refs #2984 Generated-by: Claude Code * fix(desktop): re-point the structural gates the surface move broke Deleting the import dialog left exact-string readers behind. `scripts/ check-astryx-alignment.mjs` read the dialog and its stylesheet by path, so running it exited 1 with ENOENT; the surface inventory still listed both files and had no row for the page that replaced them. The dialog's Item-row guards are not migrated, they are dropped. They checked that a SELECTED row stayed keyboard-reachable -- no parent role stealing Item's native button, no selected-only tabIndex trapping focus. 设置 › 活动 › 导入任务 has no selection: 导入 sits on each row the way 恢复 does on the archived page. Asserting those smells against the new page would guard a shape it does not have. The button guard and a ListItem import check follow the surface to the page. Inventory rows for the new page and for #2985's archived-tasks page, which was never added. Three runtime-host entries stay missing; they predate this branch. Also read the Daily Review model label from its copy table instead of a literal. The story matched '跟随对话默认', which the rename retired, so it silently found nothing -- and `storybook-visual-smoke.mjs` disables every `play` function, so CI could not report it. Refs #2984 Generated-by: Claude Code * refactor: finish the 任务 rename where the rule missed The rename was applied by rule and reviewed by hand, which is the process that produces both halves of this: strings that kept the old noun, and strings that took the new one where the old was right. Same-surface contradictions, which are what a user actually notices: - The to-do panel's error banner said 任务载入失败 next to a button labelled 重新载入待办 -- one `Banner`, two vocabularies. - `chat-readiness` said 当前任务 and then 新建会话 in one sentence. - The bot's help text offered to 清空当前对话开新会话 while the reply to that same command already said 任务已重置. - 定时任务 described itself as 安排定时任务,并回顾本机任务, and its delivery option read Agent 任务执行 -- two meanings of 任务 in one form. - `settlementFailed` called the side chat a 任务 and then a 侧边对话; `forkSetupFailed` called it 追问任务, a third name for one object. Both are 侧边对话 now, matching the en twins. zh/en divergence, where the catalogs share keys but stopped sharing meaning: Daily Review counted 任务 in zh and conversations in en (and its en empty state said conversations on one branch, tasks on the other); the terminal panel said "task" and then "the session's terminal". Missed outright: `tool-activity/copy.ts` (由源会话管理 on every terminal result chip), `connection-error-copy.ts`, and the CLI's own prose (`allow for session`, `Give this session full access`, `--continue`). Kept deliberately: 和 Maka 对话 as a behaviour, Codex's stored 对话 on the import page, 侧边对话, 对话框 for dialog, and `<session-id>` as a CLI contract. The palette's `nav:sessions` keywords get 会话 and 对话 back -- collapsing both onto 任务 left a duplicate entry and dropped the words a long-time user would still type. Refs #2984 Generated-by: Claude Code * fix(ui): route the task row's last two dots through the shared vocabulary `streaming` and `hasUnread` still wrote `'accent'` by hand while the status and stale signals resolved through `dotForStatus`. Half a function deciding its own colours is the drift this change set out to close -- the literal is what let the rail and Settings disagree about the same fact before. Both are `active`: streaming is the system working on it, and unread text is "something happened here" rather than a question waiting on the user, which is the line the two semantics exist to draw. `dotForStatus('active')` is `'accent'`, so nothing moves on screen. Also drops the `copy` binding `SessionHistoryList` no longer reads. It fed the list's own aria-label, and the outer SideNav took that name over when the rail was rebuilt. Refs #2984 Generated-by: Claude Code * docs: say what each session-status-presentation file actually owns Desktop's copy claimed the status-to-dot mapping was "re-exported below" and that `describeBlockedReason` was defined there. Both moved to `@maka/ui` in this branch and neither is in the file; what is left is Desktop-only judgment -- which blocked reasons are worth acting on, and what to offer after a turn fails -- so the header says that instead. The contract the old header carried is real and had nowhere to live after the move: a UI label must never show a raw `SessionBlockedReason`, and a new reason has to extend the core enum and the copy matrix together or it reads as `unknown`. It now sits on `describeBlockedReason` in `@maka/ui`, where the matrix it constrains is. Refs #2984 Generated-by: Claude Code * test: make the restored-task and import fixtures answer like their sources Both fixtures modelled states their real source cannot produce, which makes them evidence for nothing. The purge tests built a restored task by flipping `isArchived` alone, leaving `status: 'archived'` behind. `SessionStore.unarchive` writes both fields together, so no stored row looks like that. The sweep only reads `isArchived` today, so no assertion changes -- the point is that the next assertion written against these rows would be checking a state that cannot happen. A `restored` helper names the pair once. The 导入任务 story's `list` ignored `includeArchived` and `cursor`: it rendered the archived conversation while the filter was off and handed 加载更多 the same first page forever, under a comment claiming it demonstrated both controls. It now filters and pages, with a fourth conversation so the default view is a short first page rather than the whole list. Refs #2984 Generated-by: Claude Code * fix(desktop): tidy the two places the 任务 rename passed through twice `fake_backend` reaches the user down two paths -- the reason table, and the header check in `assertSessionCanSend`, which never gets far enough to look a connection up. They said the same sentence in two copies, so renaming 会话 to 任务 had to be done twice. One `FAKE_BACKEND_MESSAGE` now. The English workspace help came out of the rename as "Any task can switch next to the input box", which makes the task the actor and never names what it switches. It says the project. Refs #2984 Generated-by: Claude Code * chore: regenerate the Astryx surface inventory The Markdown half still listed `external-session-import-dialog.tsx` and `external-session-import.css` after this branch deleted them, because the two halves were edited by hand and `check-astryx-surface-inventory.mjs` only verifies that on-disk files appear in the inventory -- never that inventory rows still exist on disk. It is also not wired into CI, so the drift was invisible from both directions. Running the generator also picks up three files that arrived from main and were never listed: `runtime-host-profiles-section.tsx`, `runtime-host-ssh-terminal-dialog.tsx`, and `settings/runtime-host.css`. They are unrelated to this branch, but a generated file regenerated in halves is how it drifted in the first place. Refs #2984 Generated-by: Claude Code * test(ui): show every task status in the story that exists to show them Two gaps, both found by shooting the story for a before/after comparison. `review` and `done` were dropped from `statusSessions` when this branch briefly deleted them from `SESSION_STATUSES`. The enum values and their labels came back; the fixture did not, so the story that covers every status covered six of eight -- and the two missing ones are exactly the two whose colours this change decided on purpose, `attention` and `success`. `StoryFrame` also defaulted to 240px while `SessionListPanel`'s rail defaults to 260, so every story that did not pass a width was clipping the rail by 20px. That lands on the trailing slot, which is where this change puts the timestamp -- the stories could not show whether it fits. Stories that want a narrow rail still pass the width to both, which is what the note on `panelProps` is about. Refs #2984 Generated-by: Claude Code * refactor(ui): drop the rail's 任务 row Expanded, the row selected a section whose content was already on screen directly under it -- the same redundancy as the 会话 list heading this change deleted one row down, and clicking any task row does the same selection. It was kept for the collapsed rail, where the list is not rendered, on the argument that 扩展 and 定时任务 were otherwise one-way doors. That was wrong. Collapsed, the rail cannot switch tasks at all, so getting back to any task already means widening it; the titlebar's 展开侧边栏 toggle does that unconditionally (app-shell-chrome-actions.tsx renders `ChromeColumnToggle` with no guard), and `activeId` survives a section change, so the widened rail comes back with the task you left already marked. The row bought one click in a state the user is leaving regardless, and charged a permanent slot for it. `sessions` now has no control of its own on the rail. It is where you are unless you went somewhere, which is why the other two sections light up and this one has nothing to light. `streaming-remount` returns the way the product now offers -- widen, then click the row carrying `aria-current="page"` -- which is a better assertion than the old one anyway: it checks that the task survives the trip, not just that a button exists. Refs #2984 Generated-by: Claude Code * fix(runtime-host): coalesce a repeat import while the first is still running Moving 导入任务 from a dialog into a Settings page removed the only thing stopping one intent from becoming two tasks, and this branch is where that happened. `ExternalSessionImportLifecycle` refused to close the dialog while an import was active; retiring it was described here as dropping a guard against nothing. It was not. The page it was replaced by is one the user may leave mid-import -- the import continues in Desktop Main by design -- and the page's `importingId` dies with it, so coming back and pressing 导入 again issues a second request against a source that is still importing. Nothing downstream deduplicates: `treats repeats as independent copies` is a pinned behaviour, so the second request lands a second task the user now has to tell apart. The guard belongs to the Host, not to the page that asked. Import is a Host operation and the Host is the only party that knows one is running; a client knows about its own requests, which is why a second window or the CLI would have reproduced this with the page's state intact. Concurrent repeats collapse onto the first attempt's promise and both callers get its outcome, success or failure, because it is one operation. Sequential repeats are untouched -- the entry is gone by the time the first settles -- so importing the same conversation again on purpose still makes an independent copy, which the existing test continues to pin. Reported by @M4n5ter in review of #3033. Refs #2984 Generated-by: Claude Code
Summary
Archived tasks lived in the rail as a filter row. The rail is a navigator for active work — single selection, 260px, always on screen — and a filter row is the wrong shape for cleanup: you could restore or permanently delete one task from its menu, but you could not find a task among them, see which project it belonged to, or clear the archive out.
This moves them. 设置 › 活动 › 已归档任务 lists the archived tasks with their project and last activity, searches them by name or project, and clears a set of them in one pass. The rail's 已归档 row is gone, so there is one home and one set of actions rather than two.
The carrier is the one this repo already uses for entity lists. Projects, the permission centre, the provider catalog, the provider panel, Runtime Host and 关于 all render a
SettingsSectionover aList/ListItemgroup. The page went through several other shapes first — a bulk-selectionToolbar, an AstryxTablewithuseTableSelection, per-project section headers viauseTableGroupedRows— and each one was visibly foreign inside the settings surface, because a table header, column rules and a checkbox column are not the language the other six pages speak.The page owns no session state. It renders the shell's catalog and writes through the shell's row actions:
deriveSessionRail, the projection the rail itself uses, rather than a second one. No active session id is passed: the rail supplies one to highlight a row, and it also pins a family's representative to whichever revision is open, which would move a row's name, date and position here for a reason this page never shows. This matters beyond tidiness. Deleting an archived parent does not cascade to an ordinary subagent child — the retirement family is the revision family plus Agent Graph children only — so the child outlives it. The rail keeps such an orphan as a row of its own; an earlier version of this page dropped every linked child unconditionally, which left a task that清空全部reported clearing and could never actually reach. The Storybook fixture reproduces exactly that sequence: clear the four rows and a fifth appears.unarchiveSession/deleteSession, so there is one confirm, one activeId and renderer-state cleanup, and one set of toasts per operation instead of two copies that could drift apart.purgeSessionsjoins the others at the same owner. It touches only tasks still archived when it reaches them and only ids without a row action in flight, so a confirm's promise cannot widen and a sweep cannot race the rail. A rejection is not evidence a task survived — the delete IPC commits the removal before it releases renderer resources — so the rejected ids, and only those, are checked back against a fresh listing. When that listing fails the outcome says it could not verify rather than claiming one, and the first rejection travels with it so the toast can name a reason instead of a count.revisionFamily: true— a task and its revisions archive, restore and delete as one unit, never half a family.Archived tasks leave the working surfaces entirely. The
SessionFilterunion losesarchived, and with it the rail row, its copy, the nav-filter branch, and the stored-selection validator branch — a storedarchivednow fails validation and falls back tochats, which is the migration.Search follows. It was the last surface that could still target an archived task, and the command palette already skipped them (
command-palette-commands.ts), so the two ways to find a task disagreed about the same set. A hit you can only open from Settings is not a chat hit. Archived tasks also stop consuming the 200-session scan budget they shared with active ones.That leaves nothing that needs to land on an archived task, so
openSessionInChatreturns to a plainchatsselection, and the page offers no 打开. An archived task has no rail row to land on; giving it one would make "the open task is always visible in the rail" an invariant the rail does not otherwise hold. Restore first — which is one click, in the row.There is no multi-select. The middle case it would serve — restore five of eleven — is rare enough that it does not pay for a checkbox column, a select-all cell, and a batch strip that pushes the list down the moment a row is ticked. Search plus a scoped clear covers the case that actually comes up.
Smaller decisions worth naming:
purgeSessions, which skips anything no longer archived.archived-tasks, nottasks. It is a publicmaka://settings/<section>deep link, and the command palette was showing it as 任务 while the settings nav said 已归档任务.revisionFamilySessionIdscomputations removed fromapp-shell-session-row-actions.ts;flagSession,unarchiveSessionandrenameSessioneach built a family id list and never read it.Refs #2984. That proposal split delivery into two PRs, with the rail rebuild removing the 已归档 and 导入 rows. Removing a row and providing its replacement are one intent, not two — a PR that only adds the page leaves the same tasks with two homes and no acceptance criterion — so the archived half moves here. The rail rebuild keeps terminology, structure and signal convergence, and whatever is decided for 导入.
Verification
apps/desktoptypecheck: main, renderer, preload and storybook projects all clean;packages/uiclean.npx biome checkclean acrossapps/desktop/src,apps/desktop/stories,packages/ui/srcandpackages/ui/stories.node --test dist/main/__tests__/task-catalog-rows.test.js— 7 pass, down from 9 and stronger. The four projection tests are one: a single fixture holding a revision family, a linked subagent under a listed parent, an orphaned one that must be listed exactly as the rail lists it, and an active task, in an order deliberately not recency order. Mutation-checked — it kills every mutant the four killed (no rail projection, no revision folding, linked children listed, orphans hidden, no archived filter) plus the reordering mutant all four missed, because their fixture's store order happened to equal recency order. Five covermatchesArchivedTaskQuery, including that a query never matches across the name/project seam and that an unresolved project falls back to the name.node --test dist/main/__tests__/app-shell-session-purge.test.js— 6 pass, covering the sweep's semantics: whole revision families, tasks no longer archived when it reaches them, a task restored from another window while the sweep was already running, ids with a row action in flight, rejections settled against a fresh listing, and an unreadable listing reported as unverified. Mutation-checked: nine mutants, all killed — including reinstating the pre-loop snapshot of archived ids, which is the defect @M4n5ter found.node --test dist/main/__tests__/thread-search.test.js— 12 pass, including that an archived task is excluded alongside a fake-backend one.Product/Settings/Pages → Archived Tasksdrives the real page against a fixture chosen to exercise the projection — a revision family, a linked subagent with its parent present, one whose parent is gone, a task in no project, and an active task the page must drop. Its restore, delete and clear really mutate the fixture and expand revision families, so the story cannot show a list the app would not produce. Verified by hand when the page landed: 7 fixture sessions render 4 rows; searching a project name relabels the button to 删除这 2 条 and the confirm to match; clearing all four surfaces the orphaned child, which a second clear then removes; checked in zh and en.ArchivedConversationsstory was deleted with the filter it demonstrated. Also not run: the full repository suite, and the desktop e2e journeys — this page has no e2e coverage yet.Review focus
Whether
purgeSessionsbelongs oncreateAppShellSessionRowActions. It is not a row action, and the alternative was leaving the loop in the page. Putting it there is what lets the page hold nowindow.maka.sessionscall at all, and what lets it reuse the family cleanup and the in-flight registry that single-row delete already has.Second: the sweep re-reads the catalog as it reaches each task rather than trusting a snapshot taken at the start, after @M4n5ter showed that a task restored mid-sweep was deleted anyway. That narrows the window; it does not close it. Nothing on this side can stop a restore that lands between the check and the removal — only the Host, which owns the lifecycle, can require the task to still be archived as it removes it. Left out of this PR deliberately:
sessions:removegaining a lifecycle precondition is a public contract change and wants its own decision. Tracked in #3050.Third: excluding archived tasks from search is a user-visible loss for anyone who searched content inside them. The argument for it is that the command palette already did this, that the result had nowhere to land once the rail row went, and that archiving is a statement the task is out of the working set. Restoring a task from Settings puts it back in search.
Fourth: clearing a large archive is N serial
sessions:removecalls, each broadcasting and each provoking a full catalog re-read, with no progress and no cancel. Correct but not cheap. Left as is deliberately — an archive is usually small, and the cost is a property of the existing broadcast rather than of this page.Fifth: this PR does not make an ordinary subagent child retire with its parent. That is Host behaviour, it predates this branch, and single-row delete in the rail has always had it. The page now surfaces the orphan so it is at least reachable, but the underlying cascade is untouched and wants its own issue.
Checklist
Does this PR entail a change in behavior?