Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions crates/host-core/src/db/project_groups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub struct ProjectGroupRecord {
pub last_opened_at: i64,
#[serde(default)]
pub legacy: bool,
/// Roots removed from the group remain suppressed as legacy projections.
/// Roots removed without chats remain suppressed as legacy projections.
#[serde(default)]
pub detached_paths: Vec<String>,
}
Expand Down Expand Up @@ -266,6 +266,7 @@ impl Database {
.map(|root| root.path.as_str())
.filter(|path| !ordered.iter().any(|candidate| candidate == path))
.collect::<Vec<_>>();
let mut removed_without_sessions = Vec::new();
for path in &removed {
let has_sessions: bool = self.conn.query_row(
"SELECT EXISTS(
Expand All @@ -276,10 +277,8 @@ impl Database {
params![path],
|row| row.get(0),
)?;
if has_sessions {
return Err(anyhow!(
"cannot remove a folder that still has chats: {path}"
));
if !has_sessions {
removed_without_sessions.push(*path);
}
}
for path in &ordered {
Expand All @@ -306,7 +305,7 @@ impl Database {
})
.collect::<Vec<_>>();
let mut detached_paths = current.detached_paths;
for path in removed {
for path in removed_without_sessions {
if !detached_paths.iter().any(|candidate| candidate == path) {
detached_paths.push(path.to_string());
}
Expand Down
23 changes: 18 additions & 5 deletions crates/host-core/src/db/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1315,7 +1315,7 @@ fn project_group_roundtrips_roots_and_shared_context() {
}

#[test]
fn project_group_update_adjusts_roots_without_orphaning_chats() {
fn project_group_update_detaches_roots_with_chats_without_orphaning_them() {
let dir = tempfile::tempdir().unwrap();
let primary = dir.path().join("primary");
let first = dir.path().join("first");
Expand Down Expand Up @@ -1378,15 +1378,28 @@ fn project_group_update_adjusts_roots_without_orphaning_chats() {
Some(canonical_second.as_str())
);

assert!(db
let detached = db
.update_project_group(
&updated.id,
"Adjusted again",
&[primary.to_string_lossy().into()],
)
.unwrap_err()
.to_string()
.contains("still has chats"));
.expect("a root with chats can be detached from the group");
assert_eq!(detached.roots.len(), 1);
assert_eq!(detached.roots[0].path, group.primary_path);
assert_eq!(detached.detached_paths.len(), 1);

let groups = db.list_project_groups().unwrap();
assert_eq!(groups.len(), 2);
let standalone = groups
.iter()
.find(|candidate| candidate.roots[0].path == canonical_second)
.expect("the detached project's chats remain reachable");
assert!(standalone.legacy);
assert_eq!(
db.project_session_ids(&canonical_second).unwrap(),
[session.id]
);
}

#[test]
Expand Down
112 changes: 112 additions & 0 deletions crates/host-core/src/rpc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5364,6 +5364,118 @@ mod tests {
assert_eq!(updated["group"]["roots"].as_array().unwrap().len(), 2);
}

#[tokio::test]
async fn grouped_folder_with_chats_can_be_detached_and_deleted_separately() {
let data_dir = tempfile::tempdir().unwrap();
let primary = data_dir.path().join("primary");
let member = data_dir.path().join("member");
fs::create_dir_all(&primary).unwrap();
fs::create_dir_all(&member).unwrap();
let primary_input = primary.to_string_lossy().to_string();
let member_input = member.to_string_lossy().to_string();
let mut app_state = AppState::open(data_dir.path()).unwrap();
app_state.handshook = true;
let state = Arc::new(Mutex::new(app_state));

let group_response = handle_request(
state.clone(),
"project.group.create",
json!({ "name": "Grouped", "folders": [primary_input, member_input] }),
mpsc::unbounded_channel().0,
)
.await
.unwrap();
let group_id = group_response["group"]["id"].as_str().unwrap().to_string();
let primary_path = group_response["group"]["primaryPath"]
.as_str()
.unwrap()
.to_string();
let member_path = group_response["group"]["roots"][1]["path"]
.as_str()
.unwrap()
.to_string();

// This chat predates removing the member from the group.
let session_id = {
let st = state.lock().await;
sessions::create_session_with_options(
&st.db,
sessions::SessionCreateOptions {
project_path: Some(member_path.clone()),
..Default::default()
},
)
.unwrap()
.id
};

let updated = handle_request(
state.clone(),
"project.group.update",
json!({ "groupId": group_id, "name": "Grouped", "folders": [primary_path] }),
mpsc::unbounded_channel().0,
)
.await
.expect("a member with chats can be detached from the group");
assert_eq!(updated["group"]["roots"].as_array().unwrap().len(), 1);

let detached_groups = handle_request(
state.clone(),
"project.groups.list",
json!({}),
mpsc::unbounded_channel().0,
)
.await
.unwrap();
assert!(detached_groups["groups"]
.as_array()
.unwrap()
.iter()
.any(|group| {
group["legacy"] == json!(true) && group["roots"][0]["path"] == json!(member_path)
}));

let removed = handle_request(
state.clone(),
"projects.remove",
json!({ "path": member_path }),
mpsc::unbounded_channel().0,
)
.await
.expect("the detached project can then be deleted through the project action");
assert_eq!(removed["removed"], json!(true));
assert_eq!(removed["sessionsRemoved"], json!(1));
assert!(member.exists());

let groups_after_delete = handle_request(
state.clone(),
"project.groups.list",
json!({}),
mpsc::unbounded_channel().0,
)
.await
.unwrap();
assert_eq!(groups_after_delete["groups"].as_array().unwrap().len(), 1);
assert_eq!(
groups_after_delete["groups"][0]["roots"][0]["path"],
json!(primary_path)
);

let sessions_after_delete = handle_request(
state,
"session.list",
json!({}),
mpsc::unbounded_channel().0,
)
.await
.unwrap();
assert!(sessions_after_delete["sessions"]
.as_array()
.unwrap()
.iter()
.all(|session| session["id"] != json!(session_id)));
}

#[tokio::test]
async fn project_group_rpc_roundtrips_context_and_roots() {
let data_dir = tempfile::tempdir().unwrap();
Expand Down
11 changes: 7 additions & 4 deletions docs/adr/0249-chatgpt-style-logical-project-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ policy.
6. The project overflow action is named **Edit project**. The editor can change
the group name and adjust its root list through the additive
`project.group.update` capability. The primary root remains first and cannot
be removed; a root with existing chats cannot be detached.
be removed. An additional root can be detached even when it has chats; those
chats remain available under the detached path as a standalone project.
Future chats use that project's path-scoped context, while the group's shared
instructions and memory remain with the original group.
7. Group creation, editing, shared instruction editing, and shared memory editing
are additive IPC capabilities. Legacy path-scoped instruction and memory
APIs remain available for legacy single-root groups.
Expand All @@ -53,9 +56,9 @@ policy.
presentation metadata.
- The first folder remains meaningful as the default execution root and cannot
be reordered in this iteration. Additional roots can be adjusted from Edit
project, subject to chat-preservation and host ownership checks, and are
available only through explicit absolute paths that pass host canonical
containment.
project; detaching one with chats preserves those sessions as a standalone
project. Group membership and path access still pass host ownership checks
and explicit absolute paths through host canonical containment.
- Selecting another group still changes the one visible host workspace; a
background session remains bound to its own primary path.
- The native picker remains local-only. Remote project sources are not implied.
Expand Down
13 changes: 8 additions & 5 deletions docs/adr/0251-project-delete-with-owned-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ Two integrity facts constrain the fix:
4. A path that is a root of a stored multi-folder project group is refused with
a structured error. The user removes the folder from the group first, which
keeps the group's primary root valid; legacy single-root projections are not
affected and delete normally.
affected and delete normally. Edit project can detach an additional root
even when it has chats; those chats remain available as a standalone project
until the user explicitly deletes it.
5. Deleting is a renderer action behind a second confirmation that names the
project and the number of sessions it owns. On success the renderer drops the
matching renderer-local record in the same operation: the archived/pinned
Expand Down Expand Up @@ -96,10 +98,11 @@ Two integrity facts constrain the fix:
transcript directory and still look like a project in the index.
- **Delete the project's folder on disk too.** Rejected: an application action
must not remove user files.
- **Detach the root from a multi-folder group inside the same call.** Rejected
for now: `project.group.update` refuses to detach a root that has chats, and
silently rewriting group structure during a delete makes the outcome hard to
predict. The refusal path can be relaxed by a later ADR.
- **Detach the root from a multi-folder group inside the same call.** Rejected:
root membership changes remain an explicit Edit project action, separate
from the destructive project-delete confirmation. When a detached root has
chats, the edit preserves them as a standalone project so the user can review
or delete them separately.
- **Abort the running turns inside the same call.** Rejected: host-core can
settle a turn's own bookkeeping but cannot stop the agent runtime that is
still streaming into that session, so an aborted-then-deleted session can
Expand Down
10 changes: 6 additions & 4 deletions docs/spec/03-runtime/04-data-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ relational schema. The host stores one JSON record per group in the
`projectGroups` namespace, shared memory in `projectGroupMemory`, and shared
instructions in `projectGroupInstructions`. The record contains the stable group
id, display name, ordered canonical roots, primary root, timestamps, and optional
`detachedPaths`. Removed roots stay in `detachedPaths` so an old path project
record is not recreated as a standalone legacy group; sessions and files are not
deleted. Existing path projects are projected as legacy single-root groups at
read time; their path-scoped memory and filesystem instructions remain readable.
`detachedPaths`. Removed roots without sessions stay in `detachedPaths` so an old
path project record is not recreated as a standalone legacy group. A removed root
with sessions is omitted from `detachedPaths` and remains readable as a standalone
legacy group; removing a root from group membership never deletes sessions or files.
Existing path projects are projected as legacy single-root groups at read time;
their path-scoped memory and filesystem instructions remain readable.
5. **Plan/Goal checkpoints are immutable host artifacts** with recorded path,
hash, and size; the existing approval row also carries execution fields.
Startup interruption is the process-epoch fence and no work is replayed.
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ controls.
| Project reorder | press-and-move on the title (8px), or ArrowUp/ArrowDown on that title, writes contiguous normalized-path order to sidebar preferences; accent insertion line; no visible grip |
| Project archive | omitted from default view; restorable from archived view |
| Project close | removes retained tab only; durable project/sessions remain |
| Project delete | row-menu danger action behind a second confirmation that names the project and the number of its sessions; refused with a message while any of those sessions is running; removes the durable project row, those sessions, their transcripts, and its project memory; never deletes the folder on disk; a path owned by a multi-folder project group is refused with a message, and a path the host no longer knows is still removed from the list |
| Project delete | row-menu danger action behind a second confirmation that names the project and the number of its sessions; refused with a message while any of those sessions is running; removes the durable project row, those sessions, their transcripts, and its project memory; never deletes the folder on disk; a path still owned by a multi-folder project group is refused with a message until detached in Edit project, and a path the host no longer knows is still removed from the list. Detaching a root with chats preserves them under a standalone project |
| Project memory | row-menu editor reads and saves a compact list of titled or untitled memory cards for the exact project path; cards can be added, edited, and removed, the context is available in later chats, and it is never a higher-priority instruction |
| Session list | exact-path matches only; no basename grouping |
| Active group | exactly one group reflects the selected host workspace |
Expand Down
9 changes: 6 additions & 3 deletions docs/spec/04-ux/09-interaction-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,12 @@ may be retained while exactly one workspace supplies the visible shell context.
trims and persists a 1–80 Unicode-code-point group name and lists every
registered folder. The Primary folder stays first and cannot be removed;
additional folders can be added through the native multi-selection picker or
removed individually. Saving updates the host-owned group while preserving
the normalized paths, workspace identity, sessions, transcripts, and on-disk
folders. A folder with existing chats cannot be removed.
removed individually. Saving updates group membership without rewriting path
identities, workspace identity, session ownership, transcripts, or on-disk
folders. Removing an additional folder with chats detaches it as a standalone
project and keeps its chats available there. Future chats use that project's
path-scoped context; shared instructions and memory stay with the original
group.
- **Pin** toggles presentation priority. Pinned projects/conversations appear
before unpinned rows within the selected secondary order. In the sidebar, a
pinned project replaces its Folder glyph with a filled accent Star so its
Expand Down
28 changes: 25 additions & 3 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -3809,7 +3809,7 @@ identify the platform validation still needed.

- **Preconditions**: One retained logical project is visible in the sidebar and
in Settings → Project archive; it has a primary folder and one additional
folder.
folder without chats.
- **Steps**: 1) Open the project's overflow menu in the sidebar and choose
Edit project. 2) Change the name, remove the additional folder, and add it
again with the native folder picker. 3) Confirm the Primary row cannot be
Expand All @@ -3825,15 +3825,36 @@ identify the platform validation still needed.
Primary folder as the first row, and updates the root count without removing
another row. Saving persists one logical group with the adjusted roots; the
name survives restart, while normalized paths, workspace identity, sessions,
and on-disk folders remain unchanged. A root with existing chats is rejected
instead of orphaning those chats.
and on-disk folders remain unchanged.
- **Specs linked**: `04-ux/01-ui-ia.md`, `04-ux/08-component-spec.md`,
`04-ux/09-interaction-patterns.md`
- **Acceptance**: D (workspace identity), F (local presentation persistence)
- **Milestone**: M5
- **Status**: Unit-covered (`project-edit.test.mjs`,
`sidebar-preferences.test.mjs`); rendered scenario Draft

#### E2E-048c: Detach a project folder with chats and delete it

- **Preconditions**: One retained logical project has a primary folder and an
additional folder with at least one saved chat. All chats are idle.
- **Steps**: 1) Open Edit project and remove the additional folder. 2) Save and
inspect the remaining group and the standalone project row for the detached
folder. 3) Open the detached project's chat and confirm its transcript is
intact. 4) Delete the detached project using its two-click delete action.
5) Inspect the remaining group, project list, chat list, and folder on disk.
- **Expected**: Removing the folder from the group preserves its project row,
chats, and transcripts, and exposes it as a standalone project. Future chats
use the detached project's path-scoped context; shared instructions and
memory remain with the original group. The confirmed delete then removes that
project's sessions and transcripts while keeping its folder on disk; the
original group remains with its primary folder.
- **Specs linked**: `03-runtime/04-data-storage.md`,
`04-ux/08-component-spec.md`, `04-ux/09-interaction-patterns.md`, ADR 0249,
ADR 0251.
- **Acceptance**: C (project and chat interaction), F (persistence), Quality.
- **Milestone**: M5
- **Status**: Host RPC integration-covered; full UI scenario Draft

#### E2E-048A: Project session lists fold after the ten most recent rows

- **Preconditions**: One retained project contains more than ten durable
Expand Down Expand Up @@ -9287,6 +9308,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`.
| A / C / Quality — Sidebar material and settings return | E2E-LAYOUT-sidebar-settings |
| C / Quality — Destination loading and focus | E2E-087b |
| A / H / Quality — Renderer process crash recovery | E2E-RUNTIME-renderer-crash-recovery |
| C / F / Quality — Project folder with chats detaches and deletes safely | E2E-048c |
| B / F / Security — Provider copy | E2E-PROVIDER-copy-config-without-credentials |
| B / F / Quality — Selected model order | E2E-MODEL-selected-order-persists |
| E / F / Quality — MCP server timeout override | E2E-261 |
Expand Down
Loading
Loading