Skip to content

feat(desktop): add an archived tasks page to settings - #2985

Merged
Astro-Han merged 11 commits into
apache:mainfrom
Astro-Han:feat/settings-tasks-page
Aug 15, 2026
Merged

feat(desktop): add an archived tasks page to settings#2985
Astro-Han merged 11 commits into
apache:mainfrom
Astro-Han:feat/settings-tasks-page

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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.

image

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 SettingsSection over a List/ListItem group. The page went through several other shapes first — a bulk-selection Toolbar, an Astryx Table with useTableSelection, per-project section headers via useTableGroupedRows — 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:

  • Rows come from 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.
  • Restoring and deleting a single task call 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.
  • A sweep is the one thing single-row delete cannot phrase, so purgeSessions joins 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.
  • Every mutation still passes 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 SessionFilter union loses archived, and with it the rail row, its copy, the nav-filter branch, and the stored-selection validator branch — a stored archived now fails validation and falls back to chats, 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 openSessionInChat returns to a plain chats selection, 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:

  • While a search is on screen the destructive button deletes what is on screen and the confirm says so. The set is frozen at the click: a confirm names a number to a person, and one re-read afterwards could be larger than what they agreed to. Shrinking is safe and happens in purgeSessions, which skips anything no longer archived.
  • The section id is archived-tasks, not tasks. It is a public maka://settings/<section> deep link, and the command palette was showing it as 任务 while the settings nav said 已归档任务.
  • A row that cannot resolve its project says nothing rather than claiming 无项目, which was a false statement whenever the lookup failed rather than a real one about the task.
  • Each row's 恢复 button carries its own accessible name — the visible text is identical on every row — and the search box no longer matches across the seam between a task's name and its project.
  • Three dead revisionFamilySessionIds computations removed from app-shell-session-row-actions.ts; flagSession, unarchiveSession and renameSession each 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/desktop typecheck: main, renderer, preload and storybook projects all clean; packages/ui clean.
  • npx biome check clean across apps/desktop/src, apps/desktop/stories, packages/ui/src and packages/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 cover matchesArchivedTaskQuery, 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.
  • Storybook Product/Settings/Pages → Archived Tasks drives 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.
  • Not re-run by hand after the rail change: Storybook. The rail row removal is covered by the storybook build and visual smoke in CI, and the dead ArchivedConversations story 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 purgeSessions belongs on createAppShellSessionRowActions. 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 no window.maka.sessions call 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:remove gaining 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:remove calls, 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

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

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
@Astro-Han Astro-Han changed the title feat(desktop): add a Settings Tasks page for task management feat(desktop): add an archived tasks page to settings Aug 14, 2026
@Astro-Han
Astro-Han force-pushed the feat/settings-tasks-page branch from 08ffdb2 to e53fd17 Compare August 14, 2026 11:03
@Astro-Han
Astro-Han marked this pull request as ready for review August 14, 2026 11:09
…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
@Astro-Han
Astro-Han requested review from M4n5ter and MicroGery August 14, 2026 15:37
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 M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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
@Astro-Han

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in ad7499350. You're right on both counts — the snapshot, and that rechecking in the renderer only narrows the window.

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: sessions:remove gaining a lifecycle precondition is a public contract change and deserves its own decision rather than riding along in a UI change. Tracked in #3050.

@Astro-Han
Astro-Han merged commit ab3c155 into apache:main Aug 15, 2026
10 checks passed
@Astro-Han
Astro-Han deleted the feat/settings-tasks-page branch August 15, 2026 08:34
Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 15, 2026
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
Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 15, 2026
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
@Astro-Han

Copy link
Copy Markdown
Contributor Author

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. #remove compares target.revision !== input.expectedRevision inside the #withStableFamily admission lock, and removeVersioned re-checks metadataVersion in the same SQL transaction as the DELETE. Unarchiving goes through setLifecycleVersionedupdateHeaderSync, which bumps that version. So a concurrent restore does make the first remove fail with revision_conflict.

The defect is the Desktop client's retry loop. removeSession in apps/desktop/src/main/runtime-host-client.ts treats the conflict as a stale read and replays the delete with the fresh revision, up to MAX_SESSION_REVISION_ATTEMPTS (8) times. Replay is correct for rename and configuration writes — the write means the same thing at any revision. For remove it is not: the conflict is precisely the signal that the task was touched after the caller decided to destroy it, and the replay carries out a destruction whose premise no longer holds. The loop also widens the race window from "between two calls" to "the whole retry".

That makes an expected-lifecycle field on session.remove unnecessary. Because lifecycle writes always bump metadataVersion, and the version check and the DELETE share one transaction, "read as archived at revision R" plus "removed with expectedRevision R" implies "still archived when it was deleted". The loop only has to re-assert the precondition on each re-read and stop replaying blindly — no protocol change, no change to the retirement coordinator.

The fix therefore lands in apps/desktop. #3050 has been rewritten with this analysis; the version you saw proposed the Host-side precondition and was wrong about where the atomicity was missing.


Disclosure: drafted with Claude Code, which traced the code paths cited above. The reasoning here comes from reading source — #remove, removeVersioned, setLifecycleVersioned, removeSession — not from running a reproduction; your deterministic repro remains the only execution evidence on this. Reviewed and sent by @Astro-Han.

中文

问题确认,已用 #3050 跟踪。对诊断有两点修正,两点都让修复范围比建议的更小。

快照那部分已经不存在了。 合并版是在清理推进到每个任务时重读目录,而不是信任开始时的一次快照。这收窄了窗口;你真正的论点——渲染进程侧重读无法关闭这个窗口——成立,这也是 issue 存在的原因。

Host 其实已经原子地要求了,只是靠 revision 而不是 lifecycle。 #remove#withStableFamily 的准入锁内比对 target.revision !== input.expectedRevisionremoveVersioned 在与 DELETE 同一个 SQL 事务里再次校验 metadataVersion。而取消归档走 setLifecycleVersionedupdateHeaderSync,会 bump 这个版本号。所以并发恢复确实会让第一次删除以 revision_conflict 失败。

缺陷在桌面端客户端的重试循环。apps/desktop/src/main/runtime-host-client.tsremoveSession 把冲突当成读取过期,用新的 revision 重放删除,最多 MAX_SESSION_REVISION_ATTEMPTS(8)次。重放对改名和改配置是对的——这些写在任何 revision 下含义相同。对删除不是:冲突恰恰是「你决定销毁它之后有人动过它」的信号,重放执行的是一个前提已失效的销毁操作。这个循环还把竞态窗口从「两次调用之间」放大成「整个重试过程」。

这样一来,session.remove 上的 expected-lifecycle 字段就没有必要了。因为 lifecycle 写入必然 bump metadataVersion,而版本校验与 DELETE 在同一事务内,「在 revision R 读到它处于归档态」加上「以 expectedRevision R 删除成功」即可推出「删除发生时它仍处于归档态」。循环只需要在每轮重读后重新确认这个前置条件、不再盲目重放——不用改协议,也不用动 retirement coordinator。

因此修复落在 apps/desktop#3050 已按这个分析重写;你看到的那一版提出的是 Host 侧前置条件,对「原子性缺在哪里」的判断是错的。


披露:本回复由 Claude Code 起草,上述代码路径由它追溯。结论来自阅读源码(#removeremoveVersionedsetLifecycleVersionedremoveSession),没有运行复现;这件事上唯一的执行证据仍然是你的确定性复现。由 @Astro-Han 审阅后发出。

Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 15, 2026
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
Astro-Han added a commit to Astro-Han/maka-agent that referenced this pull request Aug 15, 2026
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
Astro-Han added a commit that referenced this pull request Aug 15, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants