feat(sidebar): integrate archive + drag-sort endpoints - #835
Draft
tcp404 wants to merge 9 commits into
Draft
Conversation
…820) ## Description Backend half of PR-A: the ordering base + sidebar grouping/paging API that moves the left conversation list off "pull everything and group in the browser" onto a server-driven, paged model with a real source of truth for pins. ### Migration 038 - New `user_order` table — the pin ordering source of truth (v1 scene `'pinned'`; `order_key` ascending = most-recently pinned first). - `conversations.archived_at` column + partial indexes (`idx_user_order_scene`, archived indexes) landed here so PR-B (archive) needs no further migration. - No backfill: historical `extra.pinned` is intentionally NOT migrated into `user_order` (consistent with team-pin localStorage not migrating — preference data is never migrated). ### aionui-db - `IUserOrderStore` + `SqliteUserOrderStore`: pin (`order_key = scene global min − 1000`, empty scene → `1000`), unpin, keyset paging on `(order_key, item_type, item_id)`, `BEGIN IMMEDIATE` to serialize read-min-then-insert against concurrent pins. - Sidebar read store: thin-row query + batched hydration (no N+1), anti-join against `user_order` for the unpinned side (never reads the deprecated `pinned` column). ### aionui-sidebar (new crate) - `GET /api/sidebar` — first screen: pinned → projects (real project groups + dir pseudo-groups) → chats. - `GET /api/sidebar/items` — per-group `+10` keyset paging; five-case classification + path merge live in the display layer only (no `resolve_existing`, no fs touch, no writes). - `PUT`/`DELETE /api/order/pinned/{item_type}/{item_id}` — pin/unpin, idempotent, scene enum validated (unknown → 400). - Group order = render order; in-group item order = render order. ### Cascade (§4.3, best-effort) - Conversation delete, team delete, and a conversation becoming a live team member each drop the matching `user_order` rows. Orphans self-heal on read. ### Wiring - Routes / state / service wired into `aionui-app` mirroring the project module. ## Tests - `EXPLAIN QUERY PLAN` asserts the hot pinned reads (base + expanded keyset predicate) ride `idx_user_order_scene` and never full-scan `user_order`. - Keyset continuity (no repeat / no gap), concurrent-pin serialization into distinct rows, per-user scoping. - Sidebar classification matrix (five cases × conversation/team), path-merge does not write the DB, dangling `project_id`, join-team read-side member exclusion. - Cascade coverage across the three paths. - Full workspace gate green: `just push` = 8526 passed, 47 skipped. ## Notes - Pairs with AionUi `boii/feat/sidebar` (PR #3969). Frontend requires this branch running. - Not yet live-verified end-to-end in a running desktop instance — unit/integration only. - Follow-up (separate PR): `removeProject` greenfield (BR-19/D13). Conclusion: single global transaction is infeasible — conversation delete fires hooks (agent-process kill, cron clear) and removes the fs workspace dir, none of which can live in a DB tx. It will mirror `remove_team`: per-entity best-effort orchestration, localized atomicity only. - Deprecated `conversations.pinned/pinned_at` columns are left in place (not dropped); no read/write path touches them after this PR.
## Description
Make the "is this a temporary/auto-provisioned session workspace?" check
**root-agnostic**, fixing a historical-debt bug where long-time users —
whose conversation directories were migrated across data-dir layouts —
had their temporary sessions wrongly rendered as **projects** in the
sidebar.
### Root cause
Both backends that decide "is this a temp workspace?" anchored on the
*current* `data_dir` / `work_dir` root:
1. `aionui-conversation/src/service.rs` `is_temp_session_workspace` did
`workspace.strip_prefix(work_dir.join("conversations"))` — after a
migration `extra.workspace` holds an **absolute path under the old
root**, so the strip failed and it returned `false`.
2. `aionui-conversation/src/convert.rs` badge `is_temporary_workspace`
did `Path::new(ws).starts_with(data_dir)` — same current-root anchor,
same false negative for old-root workspaces.
Symptom chain (new sidebar `classify_unit`): a temp session with no
`project_id` → path branch → `is_temp_session_workspace` false → not
folded into Chats → `canonicalize` misses (old dir may still exist
physically) → `GroupKey::Dir` → **rendered as a project**.
### Fix
The auto/temp directory leaf has carried a `-temp-` marker across
**every** historical layout (`{agent}-temp-{ts}`, dated
`YYYY/MM/DD/{label}-temp-{id}`, `team-temp-{team_id}`). That marker is
root-agnostic, so:
- `is_temp_session_workspace` now scans the path components for the
**last** `conversations` segment and matches the relative tail, dropping
the `work_dir` prefix dependency. Each `is_auto_workspace_relative_path`
arm is **tightened** to additionally require `leaf.contains("-temp-")`,
so a real user project like `/x/conversations/myproj` is not
misclassified.
- The `convert.rs` badge now delegates to the same
`is_temp_session_workspace` predicate instead of a raw
`starts_with(data_dir)`.
- The sidebar call site drops the now-removed `work_dir` argument.
This covers the migration case with negligible false-positive risk (user
projects almost never have a `-temp-` leaf) and keeps the heuristic as
the foundation — no schema migration / backfill needed.
## Testing
```bash
cargo fmt --all
cargo test -p aionui-conversation -p aionui-sidebar
```
Added regression tests deliberately mixing an **old-root temp
workspace** with a **user project**, per our verification discipline:
- `is_temp_session_workspace`: current-root temp → true (no regression);
**migrated old-root** temp → true (the fix); team temp → true; legacy
bare leaf → true; user project `/home/me/conversations/myproj` →
**false**; no `conversations` segment → false; bad date → false.
- `convert.rs` badge: old-root workspace →
`is_temporary_workspace=true`; user project under data_dir → false.
- sidebar `classify_unit`: no `project_id` + old-root temp workspace →
`GroupKey::Chats`, no longer `GroupKey::Dir`.
All green.
## Cross-platform
Path handling uses component iteration (no hardcoded separators); tests
exercise Unix-style absolute paths. No platform-specific branches
introduced.
- Recognize temp sessions by the `-temp-` leaf marker alone instead of anchoring on a data-dir root or a conversations/tmp container segment - Covers every historical auto-workspace layout the previous container-anchored check (#825) still missed: OS temp dir, bare `<data_dir>/{leaf}`, and `<data_dir>/tmp/{leaf}`, alongside the conversations bare/dated/per-user shapes - Stays root-agnostic so migrated data-dirs whose `extra.workspace` was baked under a previous root still classify correctly on the read path - Leaves the write/delete predicate `is_auto_workspace_relative_path` untouched; it still anchors on the current data-dir for precise cleanup - Accepts a user project whose own leaf contains `-temp-` as a false positive, overridden by the project row's `kind` - Rewrite the unit test as a single table-driven case covering all layouts, the negative guards, and the documented false positive
- OrderScene has a single variant, so the roundtrip loop tripped clippy::single_element_loop; inline it to a direct binding
- Add a #[cfg(windows)] twin of the layout matrix using drive-letter and backslash paths so Windows exercises native Path::file_name splitting, which POSIX literals cannot on that platform - Gate the existing matrix with #[cfg(unix)] so each OS runs its own native path forms rather than shared portable literals - Extract the assertion loop into a shared assert_temp_layout_matrix helper reused by both platform tests
- Parameterize the sidebar read engine with an ArchiveScope (Active | Archived) so the archived slice reuses the same grouped read model - Add ?archived flag to the sidebar first-screen and items endpoints - Add conversation/team archive + unarchive endpoints (archive unpins) - Add DELETE /api/archived to empty the archive in one call - Extend the sidebar store with archived_at read/write and cover the scope split, unpin-on-archive, and empty-all with service + repo tests
- Add IUserOrderStore::move_item with fractional order_key placement:
insert-at-top uses min-PIN_GAP; move-after-X takes the midpoint
between the anchor and its successor, falling back to a whole-scene
rebalance (1000,2000,...) when the gap drops below the threshold
- Run the read-modify-write in a single BEGIN IMMEDIATE txn to avoid
TOCTOU under concurrent moves
- Add POST /api/order/{scene}/move: parse scene/moved/after, reject a
self-anchor as 400, map MoveOutcome to Ok / 404 ScopeGone / 400
- Cover move, rebalance, boundaries and stale-anchor mapping in tests
- Add delete_archived_conversation / delete_archived_team service methods,
validating the id against the archived slice so an active, foreign, or
team-member id is ScopeGone (404) — the endpoint can never reach the active
slice or split a live team
- Reuse the RemoveProjectPorts seam: conversations delete directly, teams
cascade their members, mapping port errors to Internal
- Register DELETE /api/sidebar/archived/conversation/{id} and .../team/{id}
- Cover happy delete, active/foreign/unknown → 404, member id → 404 (no team
split), and team cascade + foreign-team gating
- Extract collect_project_units, sharing remove_project's classify_unit
pass so path-merged unbound conversations are caught (not a naive
WHERE project_id filter), preserving "what you see is what acts"
- Add archive_project / unarchive_project over the shared collector:
flip archived_at for every team + standalone conversation, unpin on
archive (D6); scoped to Active / Archived respectively
- Add delete_archived_project: hard-delete only the archived units and
intentionally keep the project record (empty project rows tolerated)
- Wire POST /project/{id}/archive, POST /project/{id}/unarchive,
DELETE /archived/project/{id}; each rejects non-standard/foreign
projects with ScopeGone
- Cover the three flows in service_test.rs, including the kept record
and untouched active/other-project units
tcp404
force-pushed
the
boii/feat/sidebar-integration
branch
from
August 12, 2026 16:24
16afe4a to
352a4f6
Compare
tcp404
marked this pull request as draft
August 21, 2026 13:09
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
目的
侧边栏改版的后端集成分支,配合 AionUi #4000,用于演示 + 评审。覆盖前述 6 项能力中有后端改动的部分。
main(diff 覆盖基座 + 归档 + 拖拽重排后端)。后端覆盖的能力
1. 群聊下沉到 project + 2. 会话分页 + 3. 置顶基础
分组读模型、keyset 分页、置顶 pin/unpin 端点。来源:基座 PR #832。
3. 拖拽混合重排(后端)
POST /api/order/{scene}/move:重排置顶项(moved+ 可选after锚点,after=null置顶)。陈旧窗口锚点映射 404/400 供前端刷新。来源:PR #831。4. 归档功能(后端)
conversation、team 归档/取消归档端点(团队归档级联成员会话)+ 清空归档端点。来源:PR #830。
冲突解决
归档(#830)与拖拽重排(#831)各自在同批文件上新增 export/端点/方法,merge 时冲突 3 文件,全部按保留两者解:
aionui-api-types/src/lib.rs:sidebar re-export 同时保留ArchiveDeleteResult(归档)与MoveOrderRequest, OrderItemRefDto(排序)。aionui-sidebar/src/routes.rs:use合并;move_order路由与 handler 已接入。aionui-sidebar/src/service.rs:use aionui_api_types/use aionui_db双端合并;方法块保留归档组与move_order(unpin_item补自身},move_order复用公共})。构成 PR / 分支
本地核验
cargo check -p aionui-sidebar:绿(含 aionui-db / aionui-api-types)。cargo test --workspace/ clippy 全量。