diff --git a/docs/changes/unreleased/1432-conversation-task-lifecycle.md b/docs/changes/unreleased/1432-conversation-task-lifecycle.md new file mode 100644 index 000000000..720a19b4f --- /dev/null +++ b/docs/changes/unreleased/1432-conversation-task-lifecycle.md @@ -0,0 +1,24 @@ +--- +kind: changed +title: Conversation close, task stop and permanent deletion stay synchronized across views +pr: 1432 +surface: [chat, engine, docs] +invalidates: + - "Closing a conversation could stop its agent or hide its work. Close now only closes its tab; saved conversations and active work remain available." + - "Task rows offered Close. They now offer Stop while active and confirmed Delete for a settled task and its descendants." + - "Deleting a record could leave live task bars and cached lists unchanged. Permanent deletion now removes all views immediately and survives replay." + - "Stopped work could appear finished or count as done. Conversation rows and task pages now preserve stopped status." +--- + +Home and Sessions share conversation actions, dark grey closed rows and permanent-delete +confirmation. Enter reopens; y confirms deletion; n or Escape returns to the actions. +Conversation deletion stops its agent and removes all owned tasks, while task deletion +keeps its conversation and unrelated siblings. Option+K remains usable with every tab closed. + +The dev integration keeps program badges, interrupted-run recovery and live Home +activity readings. The retired rail footer totals and its fold-state map stay +removed; stopped-state checks cover the task and room surfaces that still exist. + +The current Home menu keeps Copy name second, including on closed conversations; +its first action becomes Delete while Enter remains the reopen door. Sessions shares +these conversation actions, and task rows retain Copy project. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index 0cc0ad8e0..330d52021 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -295,15 +295,16 @@ list, the same word the sessions place uses. It never reads as its session id, i the id with its first letter raised (`D53cceead3f99593`). A conversation that has a title keeps that title. -## Close or put away a task from home — task row options, new in project, open folder, copy project +## Stop or delete a task from home — task row options, new in project, open folder, copy project -**`→` offers task options just as it does for threads:** `x close`, -`n new in project`, `o open folder`, and `p copy project` when the corresponding local -folder and action are available. On a wide home these appear beneath the description -in the middle column. Putting a task away hides that task from home and the unfiltered -Sessions list; it does not stop it, delete its record, or put away its conversation. -Find it again by typing its name in Sessions, then use `→` and `x reopen`. -The choice survives closing the app. Folder actions use the task's conversation project. +**Tasks have no Close action.** `→` offers `x stop` while the task or any descendant +is active, including queued or waiting work. Stop preserves the task records and the +conversation. Once the entire subtree has stopped, the action is `x delete`. +Deletion asks `delete is permanent, are you sure?`: `y` deletes the selected task and +all descendants; `n` or Escape restores the four actions. Enter never confirms deletion. +A Stop action remains Stop even if the task finishes while its menu is open. +The other actions are `n new in project`, `o open folder`, and `p copy project`. +Previously hidden tasks are visible again; conversation Close is unchanged. ## Why does only one row spin — the one spinner on home @@ -370,9 +371,9 @@ rows that do not fit behind `N more`. The Sessions heading opens the full Sessio typing into the box searches older history. The `alt+k chats` menu (`opt+k` on macOS) continues to list open tabs only. -Closing with `→`, then `x close`, or `ctrl+e`, removes the same conversation from -Home's open rows, the tab strip and the default chats menu immediately. It keeps -running work and drafts. A waiting conversation carries a `?`; +Closing with `→`, then `x close`, or `ctrl+e`, closes the tab and removes it from +the default chats menu immediately. Home keeps the conversation visible as closed, +including progress from its running work. It keeps running work and drafts. A waiting conversation carries a `?`; the question remains reachable even when its tab is closed. **On `space` `space` the cursor is already on the chat you were in before this one**, so a @@ -701,19 +702,42 @@ machine with one conversation and on one with none (see *Why is the home screen ## Close or archive a conversation — put junk away and clean up home -Select a conversation, press `→`, then **`x close`**. `ctrl+e` does the same. -Its tab closes and it leaves the default `alt+k chats` list immediately. Home keeps closed conversations dimmed in the same Sessions list while they are -among the fifteen most recent. The -foot says `closed · type its name to find it again`. +Select a conversation, press `→`, then **`x close`**. `ctrl+e` also closes it. +Its tab closes and leaves the default `alt+k chats` list. Home keeps the conversation +among its fifteen most recent, and search finds older ones. Running replies, tasks +and jobs keep their progress marks after the tab closes. Its draft and saved history stay. -Nothing is deleted: its transcript, tasks, running work and draft remain. Closing -from Home also saves its archived status, so the bounded closed list can find it -after a restart. Closing a tab with `ctrl+w` retains it in this window's close stack -without archiving it on disk. +**Enter reopens the conversation and its tab.** On a phone-width terminal, Enter first +opens its sheet; use the sheet's open action. Home, Sessions, the tab close button and +`ctrl+w` in the chats menu save the same closed status across restarts. Closed names +use the same dark grey on Home and Sessions, including the selected row. Running +progress stays visible. Reopening from any of these places or `ctrl+shift+t` clears +the closed status everywhere. -**Enter on a dimmed row reopens the conversation and its tab.** `→`, then `x reopen`, -or `ctrl+e`, does the same. Older closed conversations remain searchable by name. -On a phone-width terminal, Enter first opens the row's sheet; use its open action. +## Permanently delete a conversation or task — delete is permanent, are you sure? + +Home and Sessions use the same four conversation actions: **`x close`**, **`c copy name`**, +**`n new in project`**, **`o open folder`**. After Close, `x` becomes **delete**; +Enter already reopens. In Sessions, `→` opens a collapsed family first; another `→` +opens its actions. Nested tasks use `x stop` while active and `x delete` once their +whole subtree has stopped; they have no Close action. + +Delete asks **`delete is permanent, are you sure?`**. Press **`y`** to confirm or +**`n`** to cancel and return to the four actions. Escape also cancels. Enter does not +confirm deletion. + +Deleting a conversation stops its agent and removes its session folder, saved history +and all its task records. Its owned workspace is part of that folder; a borrowed +project stays. It leaves Home, Sessions, search and the reopen stack. There is no undo. +Deleting the current conversation prepares a fresh conversation first. + +Deleting a nested task removes its saved record and all descendant task records and +journals. Its conversation and unrelated sibling tasks remain. It does not cancel their work or erase references already +written into the conversation's messages. A deleted record cannot be reopened or +recreated by a late completion. Closing alone never deletes or stops work. + +Permanent deletion is available for local session folders, including the local engine. +It is absent on a remotely hosted session, whose files are on the other machine. ## Why did the list jump to the bottom when I typed — home's two shapes @@ -2464,6 +2488,6 @@ A task handed to a program such as senior-dev has no tab. It opens inside the co Closing a tab hides that conversation from tabs and the chats menu. It does not stop its work or release an agent still held by this window. Such a conversation -remains in Home history and can reopen here; it must not claim another window -holds it. The other-window label is reserved for a conversation actually held +remains in Home history and can reopen here. Its search card says `closed`, with +its current work state when applicable; it must not claim another window holds it. The other-window label is reserved for a conversation actually held elsewhere. diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index fee4da0a1..152da8577 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -451,7 +451,10 @@ second `esc` inside half a second is the rewind's door and `ctrl+c` at rest is t out, so neither is free — and you do not need one. The `esc` you already pressed started the 10-second window, and when it runs out codeaf stops waiting on its own. -**What happens at 10 seconds.** codeaf detaches from the turn: the waits codeaf holds are +## What happens if it will not stop — the ten-second limit + +After `esc` or `ctrl+c` cancels a running turn, codeaf waits at most 10 seconds. +Then codeaf detaches from the turn: the waits codeaf holds are ended and whatever request was still open to the model is aborted. A wait that ignores being cancelled — a command whose output a grandchild is still holding, say — may run on behind the detached turn; what detaching guarantees is that NOTHING IS WAITING FOR IT any @@ -856,7 +859,8 @@ inside one. a browser. Your unsent sentence, caret and attachments stay with the conversation. The -conversation remains in `alt+k` and Home, and reopening restores its tab and draft. +conversation remains on Home, with progress still visible while it works. It leaves +the open `alt+k` list; reopening restores its tab and draft. Closing the active tab selects the most recently used remaining open tab. With none left, the window goes **Home** with the current session behind it. @@ -1770,9 +1774,11 @@ every section at once and is drawn on the **control row** at the top of the list `⌕ port`. `alt+s` sorts by the next column — age, name, state, files, cost — and `alt+shift+s` turns the column you are on round; a press on one of the two column labels at the right of that control row sorts by it. `→` opens the row's -verbs, and this place has one: `s stop it`, over a task this conversation is holding that is -still queued or running. Its foot is assembled from what is true of the row under the -cursor — `enter open its room · → verbs: stop it · alt+s sort · type to filter` over a task this +conversation actions: `x close` (then `x delete` after closing), `c copy name`, +`n new in project`, and `o open folder`. A collapsed family opens before its actions. +Delete asks `delete is permanent, are you sure?`; `y` confirms, `n` restores the actions. +Enter reopens a closed row. To stop work without deleting its record, open its room +and use its Stop action. The foot names the applicable actions over a task this window is running, `enter go inside it` on a task another conversation ran, which has no room to open, and one of three clauses on a task another codeaf **window** is running: `enter go to that conversation` when this terminal is holding that conversation, which @@ -1930,7 +1936,9 @@ released Ctrl. ``` It works from the **first** session: on a fresh launch you hold one conversation, the card -has that one row, and the fold has the rest of the machine in it. +has that one row, and the fold has the rest of the machine in it. If every conversation tab is +closed, the card shows saved conversations immediately, including when only one remains. +The hide-closed option appears only while there are open rows to leave on screen. ## What the fold at the foot holds, and the tabs above the conversation @@ -2144,10 +2152,13 @@ everything else this window is not showing — so pressing it again closes the n rather than the same one, and several go in a row. Its conversation, its work and its draft are untouched; `→` reaches it and `enter` brings it back. -**Tab closing does not archive the saved conversation.** Home shows up to three -recently closed tabs as dimmed rows. `ctrl+e` or `→`, then `x close`, on Home also -archives the conversation and closes its tab, removing it from the default chats -list. Both routes keep work and drafts. Enter on a dimmed Home row reopens it. +**Every Close shares the same saved closed state.** Closing a tab, closing from +this menu, or using Close on Home or Sessions removes its tab and default chats +entry. Home and Sessions show its name in dark grey; work and drafts remain. +`→ show closed` in the chats menu includes saved closed conversations. Enter there, +on Home or Sessions, or `ctrl+shift+t` reopens it and clears the closed state everywhere. +Confirmed permanent deletion from Home or Sessions removes it from all four places +and the reopen stack. On the row marked `you are here`, the card closes and the window selects the most recently used remaining tab, or Home when none remain. This is the same action as @@ -3521,3 +3532,19 @@ Press `alt+k` (`opt+k` on macOS) to open the chats menu from a conversation. It lists the same open tabs as Home, in the tab strip's order. Select one and press Enter. Closed conversations are behind the menu's fold; Home also keeps up to three recently closed conversations dimmed below the open list. + +## Permanently delete from Home or Sessions — y confirms, n cancels + +Close the selected conversation with `→` then `x close`. Its work continues. +Open the actions again: `x delete` now asks `delete is permanent, are you sure?`. +`y` confirms. `n` or Escape cancels and restores the four actions; Enter does not confirm. +In Sessions, press `→` again after opening a collapsed family to reach these actions. +Enter on a closed row reopens it instead of deleting it. + +Confirmed conversation deletion stops its agent and removes the conversation and all +its tasks. Tasks have no Close action: `→ x stop` stops active work, including queued or waiting +tasks. Once the entire task subtree has stopped, `→ x delete` offers the same y/n +confirmation. Deletion removes the selected task and all descendants. A Stop press +never changes into Delete if the task finishes while the menu is open. +Borrowed project files and sibling tasks remain. Permanent deletion has no undo and +is not offered for remotely hosted files. diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 4f9ed6a93..733bb29bb 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -588,30 +588,42 @@ rather than home's `› type to search or start something new`. It used to show shared prompt with the correction two rows further down on the foot, which meant the loudest row on the screen was inviting a message the page cannot send. `enter` opens a task's room when this conversation is holding it, and goes inside its record card otherwise. `→` opens -the row's options: `x close`, `n new in project`, `o open folder`, and -`p copy project` where the local conversation and project are available. A task this -conversation is holding that is still queued or running also offers `s stop it`. Everything starts expanded; the list scrolls and its +a conversation's options: `x close` (then `x delete` after closing), `c copy name`, +`n new in project`, and `o open folder` where those doors are available. Task rows offer +Stop while active and Delete for a settled subtree; their copy action remains `p copy project`. Everything starts expanded; the list scrolls and its tail fades. The rule under the list is a bare line — the counts are on the section headings the list already draws, and it says `nothing matches` only when your filter has emptied the page — and the foot names only what is true of the row you are on: `enter open its room · -→ verbs: stop it`. - -## Close or put away a task, find an archived task, or reopen it - -On home or the Sessions list, select the task, press `→`, then `x close`. -The task disappears immediately from the Sessions list, including the current filter -results, and from home's panels. Its work continues if it is running; its record, -conversation, and other tasks are unchanged. The choice is saved with the conversation -and survives reopening the app. - -To recover it, change the Sessions filter or reopen the page and type its name. Search -includes put-away tasks within the selected time window; expand that window if the task is older. Select the matching -task and use `→`, then `x reopen`. `enter` can still open its record. - -`n new in project`, `o open folder`, and `p copy project` use the project of the conversation -that owns the selected task. A new chat is independent of the task. These folder actions -and per-task put-away are local capabilities; a connected remote window does not offer -them. Its existing stop action remains available when that engine supports it. +→ verbs: stop, new in project, open folder, copy project` over an active task. + +## Closed conversations in Sessions — dark grey names and synchronized tabs + +A closed conversation stays in Sessions with its name in the same dark grey as Home, +even when selected. Its running work keeps its progress marks. Closing from Home, +Sessions, the conversation tab or the chats menu saves the same state: the tab and +its default `alt+k` entry disappear. The menu's `→ show closed` fold can still find it. +Enter reopens it and restores its normal name and tab. Confirmed permanent deletion +removes the conversation immediately from the current Home or Sessions screen, as well +as tabs, the chats menu and reopen history. There is no need to leave and return; +an older engine snapshot cannot bring the deleted row back. +This also works for the current conversation: its saved folder and ownership survive +live-status updates, and deletion prepares a fresh conversation before stopping it. + +## Stop or delete a task — what happened to close or archived tasks? + +On Home or Sessions, select the task and press `→`. `x stop` stops active work, +including queued or waiting tasks and active descendants. Its records remain visible. +Tasks have no Close operation; previously hidden tasks are shown again. + +Once the whole selected subtree has stopped, `x delete` asks +`delete is permanent, are you sure?`. `y` deletes the task and all descendants; +`n` or Escape cancels and restores the four actions. Enter does not confirm deletion. +New in project, Open folder and Copy project remain available. Deletion removes rows +immediately and preserves the conversation and unrelated siblings. A Stop action stays +Stop even if the task finishes while its menu is open. + +Conversation rows retain Close followed by confirmed Delete. Closing preserves work; +confirmed conversation deletion stops its agent and deletes every task it owns. ## standing — what runs without being asked, and where to type on the standing page diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 94eecd66e..993a457a5 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -4624,12 +4624,19 @@ With a pointer, the `Stop` at the right end of a room's facts row — the second header, under the breadcrumbs — raises the same card. Strip chips do not carry a stop button. -**There is one other way to stop a task, and it asks no card.** On the **sessions** place -(`ctrl+.`, `/history`), `→` on a task this conversation is holding opens the row's verbs and -draws `s stop it`; `s` then ends it. That is two deliberate presses with the word on screen -for the second of them, which is what the card protects `x` from being without — and the -card cannot be drawn over a full-screen place anyway, so it would be a question nobody could -see. It uses the same door in the engine and answers with the same sentence. +**On Home and Sessions, `→ x stop` stops the selected task and its active descendants.** +Queued or waiting work also offers Stop. This menu action acts directly and keeps its +records visible; it never closes the conversation. A task stopped here reads `stopped` +in the conversation task column and its task page, including after reopening. Its task +page says `this task was stopped`. The task column counts it under `stopped`, not `done`. Stopping +an already stopped task leaves that status unchanged. Once the whole subtree has stopped, +`x delete` asks `delete is permanent, are you sure?`. `y` deletes the task and all its +descendants; `n` or Escape returns to the four actions. Enter never confirms Delete. +Deleted tasks disappear immediately from Home, Sessions, the conversation task column +and task mentions. Switching away and reopening the conversation does not restore them. +The remaining actions are New in project, Open folder and Copy project. Tasks have no +Close action. A Stop press cannot become Delete if work finishes while the menu is open. +The conversation and unrelated sibling records survive task deletion. **What stopping does.** A task that is RUNNING has its worker cut off where it stands: the turn it was in the middle of ends, and the task settles as `stopped`. A task still QUEUED @@ -5463,7 +5470,7 @@ under the cursor**, and never from a fixed sentence. Over a task this window is reads ``` -enter open its room · → verbs: stop it · type to filter · alt+. map · tab next place +enter open its room · → verbs: stop, new in project, open folder, copy project · type to filter · esc home ``` The last two keys are on every place and the router adds them. What comes before them @@ -5479,7 +5486,7 @@ changes with the cursor: is running*. - `enter about that window` over work this machine cannot reach at all, which opens the card naming where it is. -- `→ verbs: stop it` **only while the row has that verb** — see below. +- `→ verbs:` names the available row actions — see below. - A row whose work has raised something for you answers on the same line, behind the door: the foot reads `enter open its room · hello.txt · waiting in this conversation · alt+y`, with the page's own clauses — the fold, the verbs, the filter, the way out — giving way @@ -5495,19 +5502,16 @@ The final clauses describe the **page** rather than the row: says `esc clear the filter` instead — the one fact the box itself cannot show is that esc now means the filter and not the page. -**`→` opens the row's verbs, and the sessions place has exactly one: `s stop it`.** It is -offered over a task **this conversation is holding** that is still `queued` or `running` — -the same work the roster's own `x` can end, through the same door in the engine, and it -answers with the engine's own sentence (`stopping task 7 — its branch is kept`). A settled -task has nothing left to stop, work another conversation ran has no live worker here, and a -session whose engine has no cancel door is offered nothing — in every one of those cases the -verb is **absent**, and the foot does not name it. - -**It asks no confirmation, and that is deliberate.** The confirmation card guards `x`, which -is one bare keystroke over a list; on the strip the word `stop it` is drawn on screen and -only then does `s` mean anything, which is two deliberate presses with the verb in front of -you. The card is also not available here: it is drawn in the conversation's chrome, and a -question raised over a full-screen place would be one nobody could see. +**`→` opens four row actions:** `x stop` for active tasks or `x delete` for settled +task subtrees, `n new in project`, `o open folder`, and `p copy project`, where those +doors are available. A collapsed family opens first; another `→` reaches its actions. +Conversation rows use `x close` followed by `x delete`; Enter reopens closed conversations. + +**Delete asks `delete is permanent, are you sure?`.** `y` deletes, `n` cancels and +restores the four actions. Enter does not confirm. Deleting a conversation stops its +agent and removes all its tasks. Deleting a nested task removes its record and all +descendant task records and journals, preserving its conversation and unrelated siblings. +Conversation Close alone never stops work or deletes history. To stop a task without deleting its record, use `→ x stop` on Home or Sessions. ## Retry an incomplete or errored task — enter retry on its task screen diff --git a/internal/session/cancel.go b/internal/session/cancel.go index b5ad64442..b77009158 100644 --- a/internal/session/cancel.go +++ b/internal/session/cancel.go @@ -187,6 +187,9 @@ func (g *TaskGraph) stopFor(id uint64, why string) (string, error) { line string ) switch { + case node.stopped && node.state.settled(): + g.mu.Unlock() + return name + " is already stopped; there is nothing to stop", nil case node.stopped && node.state == TaskRunning: // Already stopping. A second press is a person leaning on a key, not a // second decision, and the honest answer is what is already happening. diff --git a/internal/session/cancel_test.go b/internal/session/cancel_test.go index 692c210cf..6fcfc86ab 100644 --- a/internal/session/cancel_test.go +++ b/internal/session/cancel_test.go @@ -212,13 +212,13 @@ func TestCancelIsIdempotentOverATask(t *testing.T) { t.Fatalf("the node was cut %d times, want once", got) } - // And once it has settled there is nothing to stop, said plainly. + // And once it has settled its earlier stop is still named as a stop. graph.complete(graph.node(id), TaskDone) line, err := agent.Cancel("task:" + itoa64(id)) if err != nil { t.Fatal(err) } - if !strings.Contains(line, "already finished") { + if !strings.Contains(line, "already stopped") { t.Fatalf("stopping settled work reads %q", line) } } diff --git a/internal/session/delete.go b/internal/session/delete.go new file mode 100644 index 000000000..cb8ae3fb5 --- /dev/null +++ b/internal/session/delete.go @@ -0,0 +1,348 @@ +package session + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/filelock" +) + +// DeleteConversation removes one stopped conversation and every record it owns. +// The journal lock is held through removal so another window cannot resume it +// between the final ownership check and deletion. Borrowed project files stay put. +func DeleteConversation(dir, id string) error { + meta, err := deletionMeta(dir, id) + if err != nil { + return err + } + file, err := os.OpenFile((Place{Dir: dir}).Transcript(), os.O_RDWR, 0) + if err != nil { + return err + } + defer file.Close() + if err := filelock.Lock(file, true, true); err != nil { + return fmt.Errorf("conversation is still in use: %w", err) + } + defer filelock.Unlock(file) + if _, err := removeTaskIndexRows(TaskIndexPath((Place{Dir: dir}).Transcript()), id, ""); err != nil { + return err + } + var failure error + reapSession(context.Background(), dir, meta, func(note string) { failure = errors.New(note) }) + if _, err := os.Lstat(dir); !errors.Is(err, os.ErrNotExist) { + if failure != nil { + return failure + } + return fmt.Errorf("could not remove conversation: %s", dir) + } + return nil +} + +// deletionMeta requires the selected folder to identify the selected owner. +// A symlink or a missing identity must never turn deletion into a parent-folder +// operation, even when the list was built from stale metadata. +func deletionMeta(dir, id string) (Meta, error) { + if dir == "" || id == "" || filepath.Base(dir) != id { + return Meta{}, fmt.Errorf("conversation identity does not match its folder") + } + info, err := os.Lstat(dir) + if err != nil { + return Meta{}, err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return Meta{}, fmt.Errorf("conversation is not a session folder") + } + meta, err := LoadMeta(dir) + if err != nil { + return Meta{}, err + } + if meta.ID != id { + return Meta{}, fmt.Errorf("conversation identity does not match its folder") + } + return meta, nil +} + +// TaskRecordTree returns the selected task and every descendant in one owner. +// The walk uses parent identities, never display order or titles. +func TaskRecordTree(rows []TaskIndexEntry, owner, task string) []TaskIndexEntry { + latest := make(map[string]TaskIndexEntry) + for _, row := range rows { + if row.SessionID == owner { + latest[row.ID] = row + } + } + wanted := map[string]bool{task: true} + for changed := true; changed; { + changed = false + for _, row := range latest { + if !wanted[row.ID] && (row.Parent != "" && wanted[row.Parent] || row.PlanID != "" && wanted[row.PlanID]) { + wanted[row.ID], changed = true, true + } + if wanted[row.ID] && row.PlanID != "" && !wanted[row.PlanID] { + wanted[row.PlanID], changed = true, true + } + } + } + var tree []TaskIndexEntry + if root, ok := latest[task]; ok { + tree = append(tree, root) + } + for id, row := range latest { + if id != task && wanted[id] { + tree = append(tree, row) + } + } + return tree +} + +// TaskRecordActive includes waiting work that can still start or resume. An +// unknown unfinished state is not permission to permanently remove a record. +func TaskRecordActive(row TaskIndexEntry) bool { + switch row.Status { + case string(TaskDone), string(TaskFailed), string(TaskUnverified), string(TaskInterrupted), "cancelled", "canceled": + return false + default: + return true + } +} + +// DeleteTaskRecord is the single-root entry point; descendants belong to it. +func DeleteTaskRecord(dir, owner, task string) error { + _, err := DeleteTaskTree(dir, owner, task, nil) + return err +} + +// DeleteTaskTree checks the whole subtree before publishing tombstones and +// removing its saved records. Index writers share this lock, so a late append +// cannot slip between the activity check and removal. Live rows supplement +// tasks that have not yet written an index record, including plan rows. +func DeleteTaskTree(dir, owner, task string, live []TaskIndexEntry) ([]string, error) { + if task == "" { + return nil, fmt.Errorf("missing task id") + } + if _, err := deletionMeta(dir, owner); err != nil { + return nil, err + } + index := TaskIndexPath((Place{Dir: dir}).Transcript()) + var ids, journals []string + err := withTaskIndexLock(index, func() error { + rows, err := taskDeletionRows(index) + if err != nil { + return err + } + // A disk record that became active after the live reading must still + // veto deletion. Neither reading can erase the other's active work. + tree := TaskRecordTree(append(append([]TaskIndexEntry(nil), rows...), live...), owner, task) + if len(tree) == 0 { + return fmt.Errorf("task record is no longer available") + } + wanted := make(map[string]bool) + for _, row := range tree { + wanted[row.ID] = true + if row.PlanID != "" { + wanted[row.PlanID] = true + } + if TaskRecordActive(row) { + return fmt.Errorf("task or its subtasks are still active; stop them first") + } + } + for _, row := range rows { + if row.SessionID == owner && wanted[row.ID] && TaskRecordActive(row) { + return fmt.Errorf("task or its subtasks are still active; stop them first") + } + } + if err := withMetaLock(dir, func() error { + meta, err := deletionMeta(dir, owner) + if err != nil { + return err + } + if meta.DeletedTasks == nil { + meta.DeletedTasks = make(map[string]bool) + } + for id := range wanted { + meta.DeletedTasks[id] = true + delete(meta.ArchivedTasks, id) + ids = append(ids, id) + } + return SaveMeta(dir, meta) + }); err != nil { + return err + } + journals, err = removeTaskIndexRowsLocked(index, owner, wanted) + return err + }) + if err != nil { + return nil, err + } + for _, journal := range journals { + // A citation never authorizes deleting a parent transcript or project file. + root := canonicalPath((Place{Dir: dir}).NodeJournals()) + path := canonicalPath(journal) + rel, err := filepath.Rel(root, path) + if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return ids, err + } + } + } + return ids, nil +} + +// taskDeletionRows reads every latest record, without the search index's cap. +// An unreadable index must refuse deletion rather than hide an active child. +func taskDeletionRows(path string) ([]TaskIndexEntry, error) { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer file.Close() + latest := make(map[string]TaskIndexEntry) + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 4096), 4<<20) + for scanner.Scan() { + var row TaskIndexEntry + if json.Unmarshal(scanner.Bytes(), &row) == nil { + latest[row.SessionID+"/"+row.ID] = row + } + } + var rows []TaskIndexEntry + for _, row := range latest { + rows = append(rows, row) + } + return rows, scanner.Err() +} + +// withTaskIndexLock serializes appends and rewrites across processes. The lock +// uses a stable sidecar because a rewrite replaces the index inode itself. +func withTaskIndexLock(path string, write func() error) error { + taskIndexMu.Lock() + defer taskIndexMu.Unlock() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer lock.Close() + if err := filelock.Lock(lock, true, false); err != nil { + return err + } + defer filelock.Unlock(lock) + return write() +} + +func taskRecordDeleted(index string, entry TaskIndexEntry) bool { + if entry.SessionID == "" || filepath.Base(entry.SessionID) != entry.SessionID { + return false + } + meta, _ := LoadMeta(filepath.Join(filepath.Dir(index), entry.SessionID)) + return TaskRecordDeleted(meta.DeletedTasks, entry.ID, entry.Parent, entry.PlanID) +} + +// removeTaskIndexRows retains every unrelated line, including unknown older +// records, rather than round-tripping the bounded public index reading. +func removeTaskIndexRows(path, owner, task string) ([]string, error) { + var tasks map[string]bool + if task != "" { + tasks = map[string]bool{task: true} + } + var journals []string + err := withTaskIndexLock(path, func() error { + var err error + journals, err = removeTaskIndexRowsLocked(path, owner, tasks) + return err + }) + return journals, err +} + +func removeTaskIndexRowsLocked(path, owner string, tasks map[string]bool) ([]string, error) { + var journals []string + src, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer src.Close() + dst, err := os.CreateTemp(filepath.Dir(path), ".tasks-delete-*") + if err != nil { + return nil, err + } + defer os.Remove(dst.Name()) + defer dst.Close() + scanner := bufio.NewScanner(src) + scanner.Buffer(make([]byte, 4096), 4<<20) + for scanner.Scan() { + var entry TaskIndexEntry + if json.Unmarshal(scanner.Bytes(), &entry) == nil && entry.SessionID == owner && (tasks == nil || tasks[entry.ID]) { + if journal := TaskRecordPath(entry.TranscriptURI); journal != "" { + journals = append(journals, journal) + } + continue + } + if _, err := dst.Write(append(append([]byte(nil), scanner.Bytes()...), '\n')); err != nil { + return nil, err + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + if err := dst.Close(); err != nil { + return nil, err + } + return journals, os.Rename(dst.Name(), path) +} + +// keepTaskRecords also filters live graph snapshots and older writers' late +// completions. Metadata is read once per owner, not once per task. +func keepTaskRecords(index string, rows []TaskIndexEntry) []TaskIndexEntry { + byOwner := make(map[string]map[string]bool) + kept := rows[:0] + for _, row := range rows { + deleted, known := byOwner[row.SessionID] + if !known && row.SessionID != "" && filepath.Base(row.SessionID) == row.SessionID { + meta, _ := LoadMeta(filepath.Join(filepath.Dir(index), row.SessionID)) + deleted = meta.DeletedTasks + byOwner[row.SessionID] = deleted + } + if !TaskRecordDeleted(deleted, row.ID, row.Parent, row.PlanID) { + kept = append(kept, row) + } + } + return kept +} + +// TaskRecordDeleted is the shared visibility rule for indexes, live notices, +// and their surface caches. The metadata tombstone outlives every projection. +func TaskRecordDeleted(deleted map[string]bool, ids ...string) bool { + for _, id := range ids { + if id != "" && deleted[id] { + return true + } + } + return false +} + +// deletedTaskRecords reads the shared authority for graph, index and plan views. +// Reading metadata also observes deletion by another attached window. +func (a *Agent) deletedTaskRecords() map[string]bool { + a.mu.Lock() + file := a.config.SessionFile + a.mu.Unlock() + if file == "" { + return nil + } + meta, _ := LoadMeta(filepath.Dir(file)) + return meta.DeletedTasks +} diff --git a/internal/session/delete_test.go b/internal/session/delete_test.go new file mode 100644 index 000000000..a49d88f12 --- /dev/null +++ b/internal/session/delete_test.go @@ -0,0 +1,272 @@ +package session + +import ( + "github.com/Agent-Field/codeaf/internal/filelock" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func deletionFixture(t *testing.T) (string, string) { + t.Helper() + bucket := t.TempDir() + for _, id := range []string{"one", "two"} { + dir := filepath.Join(bucket, id) + if err := os.MkdirAll(filepath.Join(dir, "tasks"), 0o700); err != nil { + t.Fatal(err) + } + if err := SaveMeta(dir, Meta{ID: id, Title: id, LastUserAt: time.Now()}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "transcript.jsonl"), []byte("conversation"), 0o600); err != nil { + t.Fatal(err) + } + for _, task := range []string{"1", "2"} { + journal := filepath.Join(dir, "tasks", task+".jsonl") + if err := os.WriteFile(journal, []byte("task report"), 0o600); err != nil { + t.Fatal(err) + } + appendTaskIndex(filepath.Join(bucket, taskIndexName), TaskIndexEntry{ID: task, SessionID: id, Status: string(TaskDone), Title: task, TranscriptURI: taskURI(journal)}) + } + } + return filepath.Join(bucket, "one"), filepath.Join(bucket, taskIndexName) +} + +func TestDeleteTaskRecordPermanentlyRemovesOnlyItsRecord(t *testing.T) { + dir, index := deletionFixture(t) + if err := DeleteTaskRecord(dir, "one", "1"); err != nil { + t.Fatal(err) + } + appendTaskIndex(index, TaskIndexEntry{ID: "1", SessionID: "one", Status: string(TaskDone), Title: "late completion"}) + rows := ReadTaskIndex(index) + if len(rows) != 3 { + t.Fatalf("rows=%+v", rows) + } + for _, row := range rows { + if row.SessionID == "one" && row.ID == "1" { + t.Fatal("deleted record was republished") + } + } + if _, err := os.Stat(filepath.Join(dir, "tasks", "1.jsonl")); !os.IsNotExist(err) { + t.Fatal("deleted journal remains") + } + for _, path := range []string{filepath.Join(dir, "transcript.jsonl"), filepath.Join(dir, "tasks", "2.jsonl")} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("sibling or parent lost: %v", err) + } + } +} + +func TestDeleteConversationRemovesAllTasksAndPreservesOtherConversations(t *testing.T) { + dir, index := deletionFixture(t) + if err := DeleteConversation(dir, "one"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("conversation remains") + } + rows := ReadTaskIndex(index) + if len(rows) != 2 { + t.Fatalf("rows=%+v", rows) + } + for _, row := range rows { + if row.SessionID != "two" { + t.Fatal("deleted conversation retained task records") + } + } + if _, err := os.Stat(filepath.Join(filepath.Dir(dir), "two", "tasks", "2.jsonl")); err != nil { + t.Fatal(err) + } +} + +func TestDeleteConversationRefusesHeldAndMismatchedOwners(t *testing.T) { + dir, index := deletionFixture(t) + if err := DeleteConversation(dir, "two"); err == nil { + t.Fatal("mismatched owner deleted") + } + file, err := os.OpenFile(filepath.Join(dir, "transcript.jsonl"), os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + defer file.Close() + if err := filelock.Lock(file, true, true); err != nil { + t.Fatal(err) + } + defer filelock.Unlock(file) + if err := DeleteConversation(dir, "one"); err == nil || !strings.Contains(err.Error(), "in use") { + t.Fatalf("held conversation deletion: %v", err) + } + if len(ReadTaskIndex(index)) != 4 { + t.Fatal("failed delete changed task records") + } +} + +func TestDeleteTaskRecordNeverFollowsACitationIntoProjectFiles(t *testing.T) { + dir, index := deletionFixture(t) + project := filepath.Join(t.TempDir(), "important.txt") + if err := os.WriteFile(project, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + appendTaskIndex(index, TaskIndexEntry{ID: "1", SessionID: "one", Status: string(TaskDone), Title: "untrusted citation", TranscriptURI: taskURI(project)}) + if err := DeleteTaskRecord(dir, "one", "1"); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(project); err != nil || string(raw) != "keep" { + t.Fatal("deletion followed an external citation") + } +} + +func TestDeleteConversationKeepsItsBorrowedWorkspace(t *testing.T) { + dir, _ := deletionFixture(t) + workspace := t.TempDir() + file := filepath.Join(workspace, "keep.txt") + if err := os.WriteFile(file, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + meta, _ := LoadMeta(dir) + meta.Workspace = workspace + if err := SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + if err := DeleteConversation(dir, "one"); err != nil { + t.Fatal(err) + } + if raw, err := os.ReadFile(file); err != nil || string(raw) != "keep" { + t.Fatal("borrowed workspace was deleted") + } +} + +func TestDeleteTaskRecordRemovesEarlierJournalsForTheSameTask(t *testing.T) { + dir, index := deletionFixture(t) + newer := filepath.Join(dir, "tasks", "1-retried.jsonl") + if err := os.WriteFile(newer, []byte("retry"), 0o600); err != nil { + t.Fatal(err) + } + appendTaskIndex(index, TaskIndexEntry{ID: "1", SessionID: "one", Status: string(TaskDone), Title: "retry", TranscriptURI: taskURI(newer)}) + if err := DeleteTaskRecord(dir, "one", "1"); err != nil { + t.Fatal(err) + } + for _, file := range []string{newer, filepath.Join(dir, "tasks", "1.jsonl")} { + if _, err := os.Stat(file); !os.IsNotExist(err) { + t.Fatalf("old task journal remains: %s", file) + } + } +} + +func TestDeleteTaskRecordKeepsConcurrentAppends(t *testing.T) { + dir, index := deletionFixture(t) + var writers sync.WaitGroup + for i := 0; i < 24; i++ { + writers.Add(1) + go func(i int) { + defer writers.Done() + appendTaskIndex(index, TaskIndexEntry{SessionID: "two", ID: "new-" + strconv.Itoa(i), Status: string(TaskDone), Title: "concurrent"}) + appendTaskIndex(index, TaskIndexEntry{SessionID: "one", ID: "1", Status: string(TaskDone), Title: "late completion"}) + }(i) + } + if err := DeleteTaskRecord(dir, "one", "1"); err != nil { + t.Fatal(err) + } + writers.Wait() + rows := ReadTaskIndex(index) + if len(rows) != 27 { + t.Fatalf("concurrent deletion lost or restored records: %d", len(rows)) + } + for _, row := range rows { + if row.SessionID == "one" && row.ID == "1" { + t.Fatal("deleted task returned") + } + } +} + +func TestDeleteTaskTreeRejectsActiveDescendantsAndDeletesOnlyItsSubtree(t *testing.T) { + dir, index := deletionFixture(t) + for _, state := range []string{string(TaskRunning), string(TaskQueued), "paused", "pending", ""} { + appendTaskIndex(index, TaskIndexEntry{ID: "child", Parent: "1", SessionID: "one", Title: "Child", Status: state}) + if err := DeleteTaskRecord(dir, "one", "1"); err == nil { + t.Fatalf("deleted subtree with %q child", state) + } + meta, _ := LoadMeta(dir) + if meta.DeletedTasks["1"] { + t.Fatal("refused deletion published a tombstone") + } + } + appendTaskIndex(index, TaskIndexEntry{ID: "child", Parent: "1", SessionID: "one", Title: "Child", Status: string(TaskDone)}) + appendTaskIndex(index, TaskIndexEntry{ID: "grandchild", Parent: "child", SessionID: "one", Title: "Grandchild", Status: string(TaskFailed)}) + ids, err := DeleteTaskTree(dir, "one", "1", nil) + if err != nil || len(ids) != 3 { + t.Fatalf("subtree=%v err=%v", ids, err) + } + appendTaskIndex(index, TaskIndexEntry{ID: "late-child", Parent: "1", SessionID: "one", Title: "late", Status: string(TaskDone)}) + for _, row := range ReadTaskIndex(index) { + if row.SessionID == "one" && row.ID != "2" { + t.Fatalf("deleted descendant survived: %+v", row) + } + } + meta, _ := LoadMeta(dir) + for _, id := range []string{"1", "child", "grandchild"} { + if !meta.DeletedTasks[id] { + t.Fatalf("missing tombstone %s", id) + } + } + if meta.DeletedTasks["2"] { + t.Fatal("sibling deleted") + } + if _, err := os.Stat(filepath.Join(dir, "transcript.jsonl")); err != nil { + t.Fatal(err) + } +} + +func TestDeleteTaskTreeRechecksDiskAndLiveWork(t *testing.T) { + for _, where := range []string{"disk", "live"} { + t.Run(where, func(t *testing.T) { + dir, index := deletionFixture(t) + stale := TaskIndexEntry{ID: "1", SessionID: "one", Title: "Root", Status: string(TaskDone)} + active := TaskIndexEntry{ID: "child", Parent: "1", SessionID: "one", Title: "Child", Status: string(TaskRunning)} + rows := []TaskIndexEntry{stale} + if where == "disk" { + appendTaskIndex(index, active) + } else { + rows = append(rows, active) + } + if _, err := DeleteTaskTree(dir, "one", "1", rows); err == nil { + t.Fatal("stale confirmation deleted active work") + } + }) + } +} + +func TestDeleteTaskTreeHasNoSearchIndexLimit(t *testing.T) { + dir, index := deletionFixture(t) + for i := 0; i < taskIndexRows+1; i++ { + appendTaskIndex(index, TaskIndexEntry{ID: strconv.Itoa(i + 10), Parent: "1", SessionID: "one", Title: "child", Status: string(TaskDone)}) + } + ids, err := DeleteTaskTree(dir, "one", "1", nil) + if err != nil || len(ids) != taskIndexRows+2 { + t.Fatalf("deleted=%d err=%v", len(ids), err) + } + if len(ReadTaskIndex(index)) != 3 { + t.Fatal("old descendants escaped deletion") + } +} + +func TestDeleteTaskTreeIncludesPlanIdentityAndDescendants(t *testing.T) { + dir, index := deletionFixture(t) + appendTaskIndex(index, TaskIndexEntry{ID: "1", PlanID: "t-root", SessionID: "one", Title: "Root", Status: string(TaskDone)}) + live := []TaskIndexEntry{ + {ID: "t-root", SessionID: "one", Title: "Renamed root", Status: "done"}, + {ID: "t-child", Parent: "t-root", SessionID: "one", Title: "Child", Status: "done"}, + } + ids, err := DeleteTaskTree(dir, "one", "1", live) + if err != nil || len(ids) != 3 { + t.Fatalf("ids=%v err=%v", ids, err) + } + meta, _ := LoadMeta(dir) + if !meta.DeletedTasks["t-child"] || !meta.DeletedTasks["t-root"] { + t.Fatal("plan copy survived") + } +} diff --git a/internal/session/place.go b/internal/session/place.go index 9988d4b93..8dbbdb96d 100644 --- a/internal/session/place.go +++ b/internal/session/place.go @@ -280,6 +280,8 @@ type Meta struct { // ArchivedTasks hides individual task rows without changing their execution // or putting away the conversation that owns them. IDs are local to this session. ArchivedTasks map[string]bool `json:"archivedTasks,omitempty"` + // DeletedTasks permanently withdraws saved task records, including late index writes. + DeletedTasks map[string]bool `json:"deletedTasks,omitempty"` } // LoadMeta reads a session folder's identity. A missing file, an unparsable diff --git a/internal/session/plandb_steer.go b/internal/session/plandb_steer.go index 59e6e4898..eebc9a61d 100644 --- a/internal/session/plandb_steer.go +++ b/internal/session/plandb_steer.go @@ -154,11 +154,14 @@ func (a *Agent) PlanResume(id string) error { // they are owed is the run's (stoprun.go), never the store's sentence about who // owns what. func (a *Agent) PlanCancel(id string) error { - if row, ok := a.beltRunRootRow(id); ok { + if row, ok := a.beltRunTaskRow(id); ok { _, err := a.cancelTask(row, "") return err } return a.planSteer(id, func(store *plandb.Store, task *plandb.Task) error { + if planTaskStopped(store, task) { + return nil + } if task.ID == store.RootID() { // A RUN NOBODY IS RUNNING ANY MORE. The store holds an open run and this // conversation has none going (the program ended under it), so there is diff --git a/internal/session/plandb_steer_test.go b/internal/session/plandb_steer_test.go index a0306bbfc..2745523b9 100644 --- a/internal/session/plandb_steer_test.go +++ b/internal/session/plandb_steer_test.go @@ -91,10 +91,13 @@ func TestPlanSteerWritesReachTheStore(t *testing.T) { } rows := agent.PlanTasks() for _, id := range []string{"t-gamma", "t-gamma-kid"} { - if row := planRowByID(t, rows, id); row.Status != "cancelled" { - t.Fatalf("a cancelled task reads %q, want %q", row.Status, "cancelled") + if row := planRowByID(t, rows, id); row.Status != "cancelled" || !row.Stopped { + t.Fatalf("a stopped task reads %#v, want cancelled with Stopped true", row) } } + if err := agent.PlanCancel("t-gamma"); err != nil { + t.Fatalf("already stopped: %v", err) + } } // Every steering verb answers the same two refusals: a task another diff --git a/internal/session/plandb_tasks.go b/internal/session/plandb_tasks.go index f2c94ccb0..56dcb0933 100644 --- a/internal/session/plandb_tasks.go +++ b/internal/session/plandb_tasks.go @@ -279,6 +279,7 @@ const planTrajectoryFile = "trajectory.jsonl" // ever seeded — and an empty slice (not nil) is a plan that holds only other // chats' work: the store is there and this chat's part of it is not. func (a *Agent) PlanTasks() []PlanTaskRow { + deleted := a.deletedTaskRecords() stores, plan, closeStores := a.openPlanReadHandles() defer closeStores() if len(stores) == 0 { @@ -302,6 +303,9 @@ func (a *Agent) PlanTasks() []PlanTaskRow { // counted nothing on any real run and its row wore no progress. root := store.RootID() for _, task := range tasks { + if TaskRecordDeleted(deleted, planStoreID(task.ID), planStoreID(task.ParentID)) { + continue + } row := planTaskRow(store, dir, task, spend, live) planCarriedRow(&row, carried[task.ID]) clocks.apply(&row, dir, task, root) @@ -327,6 +331,10 @@ func (a *Agent) PlanTasks() []PlanTaskRow { // task this chat did not spawn, whether it is another conversation's or no // task at all: the page is the chat's own reading of its own plan. func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { + deleted := a.deletedTaskRecords() + if deleted[planStoreID(planTaskID(id))] { + return PlanTaskPage{}, false + } stores, plan, closeStores := a.openPlanReadHandles() defer closeStores() if len(stores) == 0 { @@ -355,6 +363,9 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { var children []PlanTaskRow depths := map[string]int{task.ID: -1} for _, child := range all { + if TaskRecordDeleted(deleted, planStoreID(child.ID), planStoreID(child.ParentID)) { + continue + } row := planTaskRow(store, dir, child, spend, live) planCarriedRow(&row, carried[child.ID]) clocks.apply(&row, dir, child, store.RootID()) diff --git a/internal/session/plandb_tasks_test.go b/internal/session/plandb_tasks_test.go index 0b67bd0c0..ea12f47ef 100644 --- a/internal/session/plandb_tasks_test.go +++ b/internal/session/plandb_tasks_test.go @@ -564,6 +564,32 @@ func TestPlanStepDisplayFactsDoNotRewriteTrajectory(t *testing.T) { } } +func TestDeletedPlanTaskIsAbsentFromListingsAndPages(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha"}, plandb.TaskSpec{ID: "beta", Title: "Beta"}) + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + owner, _ := deletionFixture(t) + agent.config.SessionFile = filepath.Join(owner, "transcript.jsonl") + meta, _ := LoadMeta(owner) + meta.DeletedTasks = map[string]bool{"t-alpha": true} + if err := SaveMeta(owner, meta); err != nil { + t.Fatal(err) + } + for _, row := range agent.PlanTasks() { + if row.ID == "t-alpha" { + t.Fatal("deleted plan task returned") + } + } + if _, ok := agent.PlanTaskPage("t-alpha"); ok { + t.Fatal("deleted task page reopened") + } + if _, ok := agent.PlanTaskPage("t-beta"); !ok { + t.Fatal("sibling task page lost") + } +} + func TestRecoveredInterruptedRunMakesItsPlanRowInterrupted(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, planStoreFilename) diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index f15dc2959..7d1ad3e61 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -178,6 +178,9 @@ func (a *Agent) stopJoinedRow(run *beltRun, id uint64, why string) (string, bool // same row saying two things. It is settled as stopped instead. return a.stopUndrivenRow(id, run.row, why) } + if planTaskStopped(run.store, task) { + return name + " is already stopped; there is nothing to stop", true, nil + } if terminalStoreStatus(task.Status) { return name + " has already finished; there is nothing to stop", true, nil } @@ -209,6 +212,9 @@ func (a *Agent) endedBeltRow(id uint64, why string) (string, bool, error) { } for _, kept := range g.runRows(id) { if kept.ID == id && kept.Run == "" { + if kept.Stopped || kept.Ending == TaskEndingStopped { + return taskStopName(id, kept.Title) + " is already stopped; there is nothing to stop", true, nil + } // A ROW STILL SAYING RUNNING WITH NO RUN BEHIND IT is not finished, // and the stop is what clears it ([Agent.stopUndrivenRow]). if kept.State == TaskRunning { @@ -309,16 +315,24 @@ func (a *Agent) beltRunStopped(run *beltRun) (bool, string) { return run.stopped, run.stopReason } -// beltRunRootRow answers the row a run's own task is published under, when id -// names the live run's own task. It is how the page's stop finds the run: the -// page speaks the store's ids and the stop speaks the row's number. -func (a *Agent) beltRunRootRow(id string) (uint64, bool) { +// beltRunTaskRow translates a live plan task into its conversation row. +// Both the root and joined work must use the stop that publishes live notices. +func (a *Agent) beltRunTaskRow(id string) (uint64, bool) { a.beltMu.Lock() defer a.beltMu.Unlock() - if a.beltRun == nil || planTaskID(id) != a.beltRun.root { + if a.beltRun == nil { return 0, false } - return a.beltRun.row, true + key := planTaskID(id) + if key == a.beltRun.root { + return a.beltRun.row, true + } + for _, row := range a.beltRun.joined { + if strconv.FormatUint(row, 10) == key { + return row, true + } + } + return 0, false } // settleStoppedBeltRun ends a run a person stopped: what its workers had made diff --git a/internal/session/stoprun_test.go b/internal/session/stoprun_test.go index 2229707a3..656a7c6c5 100644 --- a/internal/session/stoprun_test.go +++ b/internal/session/stoprun_test.go @@ -368,3 +368,52 @@ func TestAStoppedRunBuysNoFurtherSummary(t *testing.T) { t.Fatalf("a stopped run's summary was asked of a model %d times, want never", got) } } + +func TestPlanStopPublishesStoppedJoinedRowAndKeepsItStopped(t *testing.T) { + agent, _, conversation, dir := stoppableBeltRun(t, 71) + if err := agent.startKnownTaskRun(context.Background(), 72, "second piece", "brief", nil, taskStand{dir: conversation, mode: TaskModeWorktree}, ""); err != nil { + t.Fatal(err) + } + if err := agent.PlanCancel("t-72"); err != nil { + t.Fatal(err) + } + check := func() { + t.Helper() + rows := agent.graph().runRows(72) + if len(rows) != 1 || rows[0].State != TaskFailed || !rows[0].Stopped { + t.Fatalf("conversation notice lost stopped status: %+v", rows) + } + if word := ProjectTask(TaskFacts{State: rows[0].State, Stopped: rows[0].Stopped, Ending: rows[0].Ending}).Word; word != "stopped" { + t.Fatalf("conversation reads %q", word) + } + } + check() + before := beltRunTaskAt(t, dir, "72").CompletedAt + if err := agent.PlanCancel("t-72"); err != nil { + t.Fatal(err) + } + if after := beltRunTaskAt(t, dir, "72").CompletedAt; !before.Equal(after) { + t.Fatal("repeated stop rewrote the ending") + } + agent.beltMu.Lock() + run := agent.beltRun + agent.beltMu.Unlock() + // A later run ending must not overwrite this task's earlier personal stop. + agent.settleJoinedRows(agent.graph(), run, time.Now(), TaskEndingSteps, []string{"72"}) + check() + lane, stop := agent.WatchTaskUpdates() + defer stop() + notices := takeTaskUpdates(t, lane, 2) + found := false + for _, notice := range notices { + if notice.ID == 72 { + found = true + if !notice.Stopped { + t.Fatal("reopened roster lost stopped status") + } + } + } + if !found { + t.Fatal("reopened roster omitted stopped task") + } +} diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index 5873ce7d6..3e20102e4 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -456,6 +456,8 @@ type TaskNotice struct { // ID is the proposal's token: a surface hands it back to // [Agent.ResolveTask]. On updates it names the node the update is about. ID uint64 + // PlanID links this graph row to its saved plan task for permanent deletion. + PlanID string // Run names the adaptive run this row belongs to. Empty means ordinary work. Run string // Node names the adaptive node inside Run. THE RUN'S OWN ROW HAS NO NODE. diff --git a/internal/session/task_index.go b/internal/session/task_index.go index 821d31bdd..362119baf 100644 --- a/internal/session/task_index.go +++ b/internal/session/task_index.go @@ -123,6 +123,8 @@ type TaskIndexEntry struct { // root. It is additive: rows written before families entered the index // decode as roots, which is exactly what they were. Parent string `json:"parent,omitempty"` + // PlanID links this graph record to its plan row without matching titles. + PlanID string `json:"planId,omitempty"` // Name is the slug an "@" mention resolves: the title, kebab-cased // ([TaskSlug]). Two tasks may share one — a project that fixed the same // crash twice — and the newest wins, because "the nil-map task" said out @@ -449,11 +451,8 @@ func (c Config) taskIndexFile() string { return TaskIndexPath(c.SessionFile) } -// taskIndexMu serializes this process's appends. Two windows on the same -// project are two processes and are not serialized by it — they are serialized -// by O_APPEND, which is what makes an append-only file the right shape here — -// but two nodes of ONE session landing at the same instant are two goroutines, -// and they are. +// taskIndexMu serializes index writes within this process. The stable sidecar +// lock also coordinates other processes when permanent deletion rewrites it. var taskIndexMu sync.Mutex // appendTaskIndex writes one row. Every failure is silence: the caller is a @@ -467,21 +466,18 @@ func appendTaskIndex(path string, entry TaskIndexEntry) { if err != nil { return } - taskIndexMu.Lock() - defer taskIndexMu.Unlock() - if directory := filepath.Dir(path); directory != "" && directory != "." { - if err := os.MkdirAll(directory, 0o700); err != nil { - return + _ = withTaskIndexLock(path, func() error { + if taskRecordDeleted(path, entry) { + return nil } - } - file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) - if err != nil { - return - } - defer file.Close() - // ONE write, so O_APPEND's atomic offset covers the whole row: a line - // assembled by two writes is a line another process may split. - _, _ = file.Write(append(line, '\n')) + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer file.Close() + _, err = file.Write(append(line, '\n')) + return err + }) } // ReadTaskIndex reads the rows at path, NEWEST FIRST, and tolerates everything. @@ -526,7 +522,7 @@ func ReadTaskIndex(path string) []TaskIndexEntry { if len(rows) > taskIndexRows { rows = rows[:taskIndexRows] } - return rows + return keepTaskRecords(path, rows) } // lastPerNode keeps ONE row per node: the last thing the file says about it. @@ -627,7 +623,7 @@ func (a *Agent) TaskIndex() []TaskIndexEntry { merged = append(merged, entry) } sortTaskIndex(merged) - return merged + return keepTaskRecords(a.config.taskIndexFile(), merged) } // liveTaskRows is this session's graph as index rows. It reads the graph @@ -856,7 +852,12 @@ func (n *TaskNode) indexEntryLocked(session string) TaskIndexEntry { // written so far while it is still running is presence's to report // ([Agent.presenceTasks]), not this file's. files, wrote := taskFileCitations(n.changed) + planID := "" + if n.spec.planID != "" { + planID = planStoreID(n.spec.planID) + } entry := TaskIndexEntry{ + PlanID: planID, ID: strconv.FormatUint(n.id, 10), Parent: taskIndexParent(n.parent), Name: TaskSlug(n.spec.title), diff --git a/internal/session/task_run.go b/internal/session/task_run.go index a628b8f7d..199a73052 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -3723,7 +3723,12 @@ func (n *TaskNode) noticeLocked(cost float64) TaskNotice { if n.nextEffort != nil { thinking = *n.nextEffort } + planID := "" + if n.spec.planID != "" { + planID = planStoreID(n.spec.planID) + } return TaskNotice{ + PlanID: planID, Thinking: thinking, ID: n.id, Title: n.spec.title, @@ -4827,6 +4832,9 @@ func (a *Agent) readsTheDecisionLocked(node *TaskNode) bool { // A surface reading both sees an in-turn update on both, exactly as a surface // holding two Submit channels sees each event on each (see [Agent.Submit]). func (a *Agent) emitTaskUpdate(notice TaskNotice) { + if taskNoticeDeleted(a.deletedTaskRecords(), notice) { + return + } event := Event{Kind: EventTaskUpdate, Tool: "propose_task", Task: ¬ice} a.mu.Lock() hub := a.hub @@ -4991,10 +4999,14 @@ func (a *Agent) replayTaskRoster(stream *eventStream) { // moment old is the same snapshot a live watcher already drew; a job // that moves after this returns will announce onto the lane itself. liveJobs := a.liveJobNotices() + deleted := a.deletedTaskRecords() graph.mu.Lock() defer graph.mu.Unlock() for i, node := range nodes { notice := node.noticeLocked(costs[i]) + if taskNoticeDeleted(deleted, notice) { + continue + } stream.send(Event{Kind: EventTaskUpdate, Tool: "propose_task", Task: ¬ice}) } // AND THE ADAPTIVE RUNS GO OUT UNDER THE SAME HOLD, for the same reason and @@ -5032,7 +5044,9 @@ func (a *Agent) replayTaskRoster(stream *eventStream) { stream.send(Event{Kind: EventJobUpdate, Job: &job}) continue } - stream.send(Event{Kind: EventTaskUpdate, Tool: "propose_task", Task: &row}) + if !taskNoticeDeleted(deleted, row) { + stream.send(Event{Kind: EventTaskUpdate, Tool: "propose_task", Task: &row}) + } } } @@ -9674,3 +9688,9 @@ func mergePaths(kept, added []string) []string { } return kept } + +// taskNoticeDeleted keeps live emissions and graph replay on the same record +// authority as TaskIndex. Nodes remain in the scheduler for dependency history. +func taskNoticeDeleted(deleted map[string]bool, notice TaskNotice) bool { + return notice.Kind != TaskKindJob && TaskRecordDeleted(deleted, strconv.FormatUint(notice.ID, 10), strconv.FormatUint(notice.Parent, 10), notice.PlanID) +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index a603d93c2..6f6a9f33e 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -2237,28 +2237,38 @@ func (a *Agent) settleJoinedRows(g *TaskGraph, run *beltRun, ended time.Time, ru notice.Title, notice.StartedAt = kept.Title, kept.StartedAt } } - if task := run.store.Task(strconv.FormatUint(id, 10)); task != nil { - notice.Stopped = planStopReason(task.Error) - if task.Status == plandb.StatusDone { - notice.State = TaskDone - } - notice.Result = strings.TrimSpace(task.Result) - notice.Report = notice.Result - // THE STORE HOLDS THE ACCOUNT OF WHAT BROKE IN ITS ERROR, and a - // failed task carries no result: a fault row with nothing to say - // would draw the bare word, so its first line is the store's own - // sentence of the break. - if notice.Report == "" && task.Status != plandb.StatusDone { - notice.Report = strings.TrimSpace(task.Error) - } - } - if notice.State != TaskDone && runEnding != "" && (cutRows[id] || cancelledByRunEnding(run.store, id)) { + notice = settledJoinedTaskNotice(notice, run.store) + if !notice.Stopped && notice.State != TaskDone && runEnding != "" && (cutRows[id] || cancelledByRunEnding(run.store, id)) { notice.Ending = runEnding } a.publishRunRow(g, notice) } } +// settledJoinedTaskNotice reads the task's saved outcome before the run's +// ending is applied, so a person's stop keeps its own meaning. +func settledJoinedTaskNotice(notice TaskNotice, store *plandb.Store) TaskNotice { + if task := store.Task(strconv.FormatUint(notice.ID, 10)); task != nil { + notice.Stopped = planTaskStopped(store, task) + if notice.Stopped { + notice.Ending = TaskEndingStopped + } + if task.Status == plandb.StatusDone { + notice.State = TaskDone + } + notice.Result = strings.TrimSpace(task.Result) + notice.Report = notice.Result + // THE STORE HOLDS THE ACCOUNT OF WHAT BROKE IN ITS ERROR, and a + // failed task carries no result: a fault row with nothing to say + // would draw the bare word, so its first line is the store's own + // sentence of the break. + if notice.Report == "" && task.Status != plandb.StatusDone { + notice.Report = strings.TrimSpace(task.Error) + } + } + return notice +} + // cancelledByRunEnding answers whether a joined row's task was cancelled by the // run's own ending rather than by a person. // diff --git a/internal/session/taskroster_test.go b/internal/session/taskroster_test.go index ca617b806..48bad0ae8 100644 --- a/internal/session/taskroster_test.go +++ b/internal/session/taskroster_test.go @@ -1,6 +1,7 @@ package session import ( + "path/filepath" "testing" "time" ) @@ -84,3 +85,45 @@ func TestANewTaskLaneOnAnEmptyGraphStaysSilent(t *testing.T) { case <-time.After(200 * time.Millisecond): } } + +// A real deletion must agree with the roster on a live engine and a new engine +// restored from the same checkpoint. The untouched last row is a stream barrier. +func TestDeletedTaskNeverReturnsThroughLiveOrRestoredRoster(t *testing.T) { + dir, _ := deletionFixture(t) + file := filepath.Join(dir, "transcript.jsonl") + document := taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 3, Nodes: []taskRecord{ + {ID: 1, Title: "Delete parent", State: TaskDone, PlanID: "one"}, + {ID: 2, Title: "Delete child", State: TaskDone, Parent: 1}, + {ID: 3, Title: "Keep sibling", State: TaskDone}, + }} + agent, workspace := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = file }) + agent.graph().rehydrate(document, workspace, TaskSettleAsk) + live := agent.liveTaskRows() + for i := range live { + live[i].SessionID = "one" + } + if _, err := DeleteTaskTree(dir, "one", "1", live); err != nil { + t.Fatal(err) + } + for attempt := 0; attempt < 2; attempt++ { + if attempt == 1 { + agent.Close() + agent, workspace = newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = file }) + agent.graph().rehydrate(document, workspace, TaskSettleAsk) + } + lane, stop := agent.WatchTaskUpdates() + if got := takeTaskUpdates(t, lane, 1)[0].ID; got != 3 { + stop() + t.Fatalf("replayed deleted task %d", got) + } + agent.emitTaskUpdate(TaskNotice{ID: 1, State: TaskDone}) + agent.emitTaskUpdate(TaskNotice{ID: 2, Parent: 1, State: TaskDone}) + agent.emitTaskUpdate(TaskNotice{ID: 99, PlanID: "t-one", State: TaskDone}) + agent.emitTaskUpdate(TaskNotice{ID: 3, State: TaskDone}) + if got := takeTaskUpdates(t, lane, 1)[0].ID; got != 3 { + stop() + t.Fatalf("emitted deleted task %d", got) + } + stop() + } +} diff --git a/internal/session/world.go b/internal/session/world.go index caae9ca89..5a6e6c04e 100644 --- a/internal/session/world.go +++ b/internal/session/world.go @@ -270,6 +270,7 @@ type SessionRow struct { Archived bool // ArchivedTasks is the person's per-task visibility choice from metadata. ArchivedTasks map[string]bool + DeletedTasks map[string]bool } // NeedsPerson reports that this conversation is stopped waiting on somebody. It @@ -518,6 +519,7 @@ func (w *World) Adopt(root string, seed SessionRow, now time.Time) bool { Open: InUse(transcript), Archived: meta.Archived, ArchivedTasks: meta.ArchivedTasks, + DeletedTasks: meta.DeletedTasks, Places: metaPlaces(meta), } row.Presence, row.Live = ReadSessionPresence(dir, now) @@ -699,6 +701,7 @@ func readSessionRow(dir, id string, now time.Time) (SessionRow, bool) { Live: live, Archived: meta.Archived, ArchivedTasks: meta.ArchivedTasks, + DeletedTasks: meta.DeletedTasks, Places: metaPlaces(meta), }, true } diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 5e9589921..961cceaf4 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -2381,7 +2381,9 @@ type app struct { // surface in which a bare letter is a verb rather than a character // (verbstrip.go). Closed — which is nearly always — every printable key // belongs to the composer. - strip verbStrip + strip verbStrip + deleteBusy bool + deletedRecords map[tasksKey]bool // mapShowing is `alt+.`: the whole key map drawn in the cells a person was // already reading, until the next key (SCREEN 3b). A terminal cannot see a // held modifier, so what the mockup drew as "hold alt" is a chord that lasts @@ -3592,6 +3594,12 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // switch ever sees them and hands on everything else untouched and in the order // it arrived (coalesce.go). func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { + if a.deleteBusy { + switch msg.(type) { + case tea.KeyPressMsg, tea.MouseClickMsg: + return a, nil + } + } // THE LINK'S ONE-SHOT NEWS IS DRAINED HERE AND NOWHERE ELSE (hostlink.go). // The seam forgets the sentence as it hands it over, so a second caller // would not show it twice — it would swallow it. This is the one place the @@ -3600,6 +3608,13 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // not survive has news, and a person who presses a key gets it rather than // waiting for whatever repaints next. a.takeLinkNotice() + if a.strip.prompt != "" { + if key, ok := msg.(tea.KeyPressMsg); ok { + if cmd, took := a.stripKey(key); took { + return a, cmd + } + } + } // THE TEAMS PAGE TAKES WHAT IS ITS OWN AND HANDS THE REST TO THE MANAGER'S // CONVERSATION it hosts (teamspagehost.go). One comparison on every other // place. @@ -3607,6 +3622,8 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { return a, cmd } switch msg := msg.(type) { + case recordDeletedMsg: + return a, a.recordDeleted(msg) case tea.WindowSizeMsg: // A zero size is a terminal that could not say — a headless boot, a // window being born. Keeping the last known size draws something; diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go index 5ef61b5c8..62c0a8c25 100644 --- a/internal/tui3/chattabs.go +++ b/internal/tui3/chattabs.go @@ -1498,6 +1498,15 @@ func (a *app) lastVisibleTab() (chatTab, bool) { // that one function, so there is no road back onto the screen that forgets to // put the tab back. func (a *app) tabShutKey(key string) { + if err := a.saveConversationClosed(key, true); err != nil { + a.taskRowNotice("could not close conversation: " + err.Error()) + return + } + a.tabShutSaved(key) +} + +// tabShutSaved updates the window after its saved closure has succeeded. +func (a *app) tabShutSaved(key string) { if key == "" { return } diff --git a/internal/tui3/conversationclosed.go b/internal/tui3/conversationclosed.go new file mode 100644 index 000000000..27c523d73 --- /dev/null +++ b/internal/tui3/conversationclosed.go @@ -0,0 +1,30 @@ +package tui3 + +import ( + "errors" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// saveConversationClosed gives tab gestures the same saved state as Home and +// Sessions. Unsaved tabs have no metadata, and a remote path never names a +// folder on this machine. This runs on navigation, never on a frame. +func (a *app) saveConversationClosed(key string, closed bool) error { + if key == "" || a.hosted() { + return nil + } + dir := filepath.Dir(key) + meta, err := session.LoadMeta(dir) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if meta.ID == "" || meta.ID != filepath.Base(dir) || meta.Archived == closed { + return nil + } + return a.writeHomeArchived(session.SessionRow{Dir: dir, ID: meta.ID}, closed) +} diff --git a/internal/tui3/conversationclosed_test.go b/internal/tui3/conversationclosed_test.go new file mode 100644 index 000000000..dacc6456c --- /dev/null +++ b/internal/tui3/conversationclosed_test.go @@ -0,0 +1,368 @@ +package tui3 + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func pointSessionsConversation(t *testing.T, a *app, file string) { + t.Helper() + for _, at := range a.taskSheet.stops(a) { + a.taskSheet.cursor = at + if chat, ok := a.taskSheetChat(); ok && chat.row.Transcript == file { + return + } + } + t.Fatal("conversation missing from Sessions") +} + +func assertConversationClosedEverywhere(t *testing.T, a *app, file string, closed bool) { + t.Helper() + meta, err := session.LoadMeta(filepath.Dir(file)) + if err != nil || meta.Archived != closed { + t.Fatalf("saved closed=%v, want %v: %v", meta.Archived, closed, err) + } + foundTab, foundMenu := false, false + for _, tab := range a.tabList() { + if tab.file == file { + foundTab = true + } + } + rows, _, _ := a.hopReading(false) + for _, row := range rows { + if row.file == file { + foundMenu = true + } + } + if foundTab == closed || foundMenu == closed { + t.Fatalf("closed=%v, tab=%v, default menu=%v", closed, foundTab, foundMenu) + } + rows, _, _ = a.hopReading(true) + found := false + for _, row := range rows { + if row.file == file { + found = true + } + } + if !found { + t.Fatal("saved conversation absent from the expanded chats menu") + } + drain(t, a, a.openHome()) + a.home.point(file) + line, ok := a.home.focusedLine() + if !ok || line.row.Transcript != file || line.cell.closed != closed { + t.Fatal("Home disagrees about closure") + } + drain(t, a, a.showPage(pageTasks)) + r := a.tasksFiltered() + lines := r.lay(a.taskSheetListWidth()) + for at, line := range lines { + if line.kind != tasksLineChat || line.chat.row.Transcript != file { + continue + } + if line.chat.row.Archived != closed { + t.Fatal("Sessions disagrees about closure") + } + // Selection must not brighten a closed name; progress retains its own mark. + for _, lit := range []bool{false, true} { + painted := r.paint(lines, at, a.taskSheetListWidth(), a.pal, lit) + if closed && !strings.Contains(painted, a.pal.dim(line.chat.title)) { + t.Fatalf("closed name is not dark grey: %q", painted) + } + if !closed && !strings.Contains(painted, placeSubject(line.chat.title, lit, a.pal)) { + t.Fatal("reopened name is still dim") + } + } + return + } + t.Fatal("conversation missing from Sessions") +} + +func TestConversationClosureStaysInSyncAcrossEveryDoor(t *testing.T) { + for _, width := range []int{50, 80, 120, 180} { + for _, door := range []string{"home", "sessions", "tab", "chats"} { + t.Run(fmt.Sprintf("%d/%s", width, door), func(t *testing.T) { + a, files := homeTabsFixture(t) + a.width = width + open := a.open + a.open = func(workspace, transcript string) (Conversation, error) { + conv, err := open(workspace, transcript) + meta, _ := session.LoadMeta(filepath.Dir(transcript)) + conv.Agent = &twoTitleAgent{fakeAgent: &fakeAgent{model: "m"}, full: meta.Title} + return conv, err + } + file := files[1] + switch door { + case "home": + a.home.point(file) + drive(t, a, key("right"), key("x")) + case "sessions": + drain(t, a, a.showPage(pageTasks)) + pointSessionsConversation(t, a, file) + drive(t, a, key("right"), key("right"), key("x")) + case "tab": + tab, _ := chatTabAt(a.tabList(), a.convKey(file)) + drain(t, a, a.tabDismiss(tab)) + case "chats": + a.hopOpen() + for i, row := range a.hop.rows { + if row.file == file { + a.hop.at = i + } + } + drain(t, a, a.hopAway()) + a.hopClose() + } + assertConversationClosedEverywhere(t, a, file, true) + // Reopen from the chats menu, which must clear both saved and window state. + a.hopOpen() + a.hopSpread(true) + for i, row := range a.hop.rows { + if row.file == file { + a.hop.at = i + } + } + drain(t, a, a.hopTake()) + assertConversationClosedEverywhere(t, a, file, false) + }) + } + } +} + +func TestDeleteRemovesConversationFromEveryNavigationSurface(t *testing.T) { + for _, door := range []string{"home", "sessions"} { + t.Run(door, func(t *testing.T) { + a, files := homeTabsFixture(t) + file := files[1] + if door == "home" { + a.home.point(file) + drive(t, a, key("right"), key("x")) + a.home.point(file) + drive(t, a, key("right"), key("x"), key("y")) + } else { + drain(t, a, a.showPage(pageTasks)) + pointSessionsConversation(t, a, file) + drive(t, a, key("right"), key("right"), key("x")) + pointSessionsConversation(t, a, file) + drive(t, a, key("right"), key("right"), key("x"), key("y")) + } + if _, err := os.Stat(file); !os.IsNotExist(err) { + t.Fatal("deleted conversation still exists") + } + for _, tab := range append(a.tabList(), a.closedTabs...) { + if tab.file == file { + t.Fatal("deleted conversation remains in tabs or reopen history") + } + } + rows, _, _ := a.hopReading(true) + for _, row := range rows { + if row.file == file { + t.Fatal("deleted conversation remains in chats menu") + } + } + drain(t, a, a.openHome()) + for _, line := range a.home.lines { + if line.row.Transcript == file { + t.Fatal("deleted conversation remains in Home") + } + } + drain(t, a, a.showPage(pageTasks)) + for _, row := range a.tasksFiltered().chats { + if row.Transcript == file { + t.Fatal("deleted conversation remains in Sessions") + } + } + }) + } +} + +func TestSwitchingAwayDoesNotReopenAClosedForegroundConversation(t *testing.T) { + a, files := homeTabsFixture(t) + a.home.point(files[0]) + drive(t, a, key("right"), key("x")) + a.home.point(files[1]) + drive(t, a, key("enter")) + assertConversationClosedEverywhere(t, a, files[0], true) + // The browser-style reopen shortcut restores the same saved state, too. + drain(t, a, a.reopenClosedTab()) + assertConversationClosedEverywhere(t, a, files[0], false) +} + +func TestSessionsDeleteCurrentConversationKeepsItsSavedIdentity(t *testing.T) { + a, files := homeTabsFixture(t) + old := &fakeAgent{model: "m"} + next := &fakeAgent{model: "m"} + a.agent = old + a.start = func(string) (Conversation, error) { + return Conversation{Agent: next, SessionFile: files[5], Workspace: a.workspace}, nil + } + drain(t, a, a.showPage(pageTasks)) + pointSessionsConversation(t, a, files[0]) + chat, ok := a.taskSheetChat() + if !ok || chat.row.Dir != filepath.Dir(files[0]) { + t.Fatalf("Sessions lost the current conversation's saved folder: %+v", chat.row) + } + drive(t, a, key("right"), key("right"), key("x")) + meta, _ := session.LoadMeta(filepath.Dir(files[0])) + if !meta.Archived { + t.Fatal("Sessions Close did not save the current conversation's closed state") + } + pointSessionsConversation(t, a, files[0]) + drive(t, a, key("right"), key("right"), key("x"), key("y")) + if _, err := os.Stat(files[0]); !os.IsNotExist(err) { + t.Fatalf("Sessions failed to delete the current conversation: %v; notice %q", err, a.taskSheet.actionNote) + } + if a.agent != next || old.closes != 1 || old.stops != 1 { + t.Fatal("Sessions deletion did not stop and replace the old agent exactly once") + } + if !a.at(pageTasks) { + t.Fatal("deletion left Sessions") + } +} + +func TestSessionsLiveRowRetainsSavedOwnershipAndClosureMetadata(t *testing.T) { + a, files := homeTabsFixture(t) + meta, err := session.LoadMeta(filepath.Dir(files[0])) + if err != nil { + t.Fatal(err) + } + meta.Owned = true + meta.Archived = true + meta.ArchivedTasks = map[string]bool{"closed-task": true} + meta.DeletedTasks = map[string]bool{"deleted-task": true} + if err := session.SaveMeta(filepath.Dir(files[0]), meta); err != nil { + t.Fatal(err) + } + drain(t, a, a.showPage(pageTasks)) + pointSessionsConversation(t, a, files[0]) + chat, ok := a.taskSheetChat() + if !ok || chat.row.Dir != filepath.Dir(files[0]) || !chat.row.Owned || !chat.row.ArchivedTasks["closed-task"] || !chat.row.DeletedTasks["deleted-task"] { + t.Fatalf("live status erased saved ownership or task visibility: %+v", chat.row) + } + // A conversation younger than the latest world scan still knows its folder. + a.owned = true + row := a.taskSheetSelfRow() + if row.Dir != filepath.Dir(files[0]) || row.ID != filepath.Base(row.Dir) || !row.Owned { + t.Fatalf("unscanned live identity is incomplete: %+v", row) + } +} + +func TestDeletedConversationLeavesTheCurrentFrameWithAStaleWorld(t *testing.T) { + for _, page := range []string{"home", "sessions"} { + for _, current := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/current=%v", page, current), func(t *testing.T) { + a, files := homeTabsFixture(t) + index := 1 + if current { + index = 0 + } + file := files[index] + title := fmt.Sprintf("Conversation %d", index+1) + // A local engine's cached world can lag behind the successful disk delete. + stale := a.readWorld() + a.world = func() (session.World, bool) { return stale, true } + if current { + a.start = func(string) (Conversation, error) { + return Conversation{Agent: &twoTitleAgent{fakeAgent: &fakeAgent{model: "m"}, full: "Replacement"}, SessionFile: files[5], Workspace: a.workspace}, nil + } + } + if page == "home" { + a.home.point(file) + drive(t, a, key("right"), key("x")) + a.home.point(file) + drive(t, a, key("right"), key("x"), key("y")) + } else { + drain(t, a, a.showPage(pageTasks)) + pointSessionsConversation(t, a, file) + drive(t, a, key("right"), key("right"), key("x")) + pointSessionsConversation(t, a, file) + drive(t, a, key("right"), key("right"), key("x"), key("y")) + } + if _, err := os.Stat(file); !os.IsNotExist(err) { + t.Fatalf("delete did not complete: %v", err) + } + // Inspect the very next frame without reopening or manually refreshing it. + frame := placeFrameText(a) + if strings.Contains(frame, title) { + t.Fatalf("deleted conversation remained on the current %s frame:\n%s", page, frame) + } + if page == "home" && !a.at(pageHome) || page == "sessions" && !a.at(pageTasks) { + t.Fatal("deletion navigated away from the current page") + } + // An old presence response cannot rebuild a task under the deleted owner. + owner := filepath.Base(filepath.Dir(file)) + a.away = elsewhereCache{read: true, at: a.now(), held: session.NewElsewhere(a.now(), map[string]string{owner: title}, + window(owner, session.PresenceTask{ID: "late", Title: "Work from a deleted conversation", State: "working"}))} + // An old snapshot on a subsequent refresh cannot resurrect the row either. + a.refreshRecordLists() + if frame := placeFrameText(a); strings.Contains(frame, title) || strings.Contains(frame, "Work from a deleted conversation") { + t.Fatal("stale refresh resurrected the deleted conversation or its work") + } + if len(a.taskSheetAwayRows()) != 0 { + t.Fatal("stale presence retained the deleted conversation's work") + } + for _, row := range stale.Sessions() { + if row.Transcript == file { + return + } + } + t.Fatal("filtering modified the engine's shared snapshot") + }) + } + } +} + +func TestChatsMenuOpensWhenEveryConversationTabIsClosed(t *testing.T) { + for _, shared := range []bool{false, true} { + for _, count := range []int{1, 3} { + for _, page := range []string{"home", "sessions"} { + t.Run(fmt.Sprintf("shared=%v/count=%d/%s", shared, count, page), func(t *testing.T) { + lab := newHomeLab(t) + workspace := lab.workspace("project") + var files []string + for i := 0; i < count; i++ { + files = append(files, lab.session("project", fmt.Sprintf("%016d", i+1), fmt.Sprintf("Saved conversation %d", i+1), workspace, lab.pin(time.Now()))) + } + a := lab.app(files[0]) + a.shared = shared + for _, file := range files { + a.tabShutKey(a.convKey(file)) + } + if page == "home" { + drain(t, a, a.openHome()) + } else { + drain(t, a, a.showPage(pageTasks)) + } + drive(t, a, key(hopOpenKey)) + if !a.hopShowing() || len(a.hop.rows) != count || !a.hop.all { + t.Fatalf("closed conversations have no visible menu: open=%v rows=%d all=%v", a.hop.open, len(a.hop.rows), a.hop.all) + } + if len(a.hopCardLines(a.width, a.height, a.pal)) == 0 { + t.Fatal("the menu has no drawn card") + } + drive(t, a, key("left")) + if !a.hopShowing() { + t.Fatal("hiding closed rows made the entire menu invisible") + } + drive(t, a, key("esc")) + if a.hop.open { + t.Fatal("escape failed to dismiss the menu") + } + drive(t, a, key(hopOpenKey)) + selected := a.hop.rows[a.hop.at].file + drive(t, a, key("enter")) + meta, err := session.LoadMeta(filepath.Dir(selected)) + if err != nil || meta.Archived || a.file != selected || a.hop.open { + t.Fatalf("Enter failed to reopen selected conversation: file=%q closed=%v err=%v", a.file, meta.Archived, err) + } + }) + } + } + } +} diff --git a/internal/tui3/deleteconversation.go b/internal/tui3/deleteconversation.go new file mode 100644 index 000000000..a8a8fe04d --- /dev/null +++ b/internal/tui3/deleteconversation.go @@ -0,0 +1,282 @@ +package tui3 + +import ( + "fmt" + "path/filepath" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" +) + +const deletePermanentWord = "delete is permanent, are you sure?" + +// askRecordDelete captures the selected owner before replacing its four actions +// with yes/no. Cancelling restores the same actions without moving the cursor. +func (a *app) askRecordDelete(row session.SessionRow, task *session.TaskIndexEntry) tea.Cmd { + normal := verbStrip{open: true, row: a.rowIdentity(), verbs: a.rowVerbs()} + target := row + var entry *session.TaskIndexEntry + if task != nil { + copy := *task + entry = © + } + detail := "stops this conversation and deletes all its tasks" + if entry != nil { + detail = "deletes this task and all its subtasks; keeps the conversation" + } + a.strip = verbStrip{open: true, row: normal.row, prompt: deletePermanentWord + "\n" + detail, verbs: []verb{ + {key: 'y', word: "yes", do: func() tea.Cmd { return a.deleteRecord(target, entry) }}, + {key: 'n', word: "no", do: func() tea.Cmd { a.strip = normal; return nil }}, + }} + return nil +} + +type recordDeletedMsg struct { + key string + front bool + row session.SessionRow + task *session.TaskIndexEntry + replacement Conversation + stopped bool + err error + sessions bool + taskIDs []string + attached Conversation +} + +// deleteRecord runs shutdown and disk removal off the update loop. The selected +// row cannot change while this explicit deletion is finishing, and no other +// conversation is stopped to delete a task's saved record. +func (a *app) deleteRecord(row session.SessionRow, task *session.TaskIndexEntry) tea.Cmd { + if a.deleteBusy { + return nil + } + key := a.convKey(row.Transcript) + front := key != "" && key == a.frontTabKey() + var owner Agent + if front { + owner = a.agent + } else if held := a.behind[key]; held != nil { + owner = held.conv.Agent + } + start, open := a.start, a.open + where := row.Workspace + if row.Owned { + where = row.ProjectDir + } + sessions := a.at(pageTasks) + var taskRows []session.TaskIndexEntry + if task != nil { + taskRows = a.taskActionRows(row, *task) + } + a.deleteBusy = true + a.taskRowNotice("deleting…") + return a.offLoop(func() func(bool) tea.Cmd { + out := recordDeletedMsg{key: key, front: front, row: row, task: task, sessions: sessions} + done := func(bool) tea.Cmd { return a.recordDeleted(out) } + if task != nil { + if owner == nil && session.InUse(row.Transcript) { + if open == nil || a.shared { + out.err = fmt.Errorf("open this conversation before deleting its task records") + return done + } + out.attached, out.err = open(row.Workspace, row.Transcript) + if out.err != nil { + return done + } + if filepath.Clean(out.attached.SessionFile) != filepath.Clean(row.Transcript) { + out.err = fmt.Errorf("conversation identity changed; task was not deleted") + return done + } + owner = out.attached.Agent + } + rows, _ := taskActionReading(owner, row.ID, taskRows) + out.taskIDs, out.err = session.DeleteTaskTree(row.Dir, row.ID, task.ID, rows) + return done + } + // A replacement is created before ending the foreground agent, so a failed + // new-conversation door cannot strand the surface without a usable agent. + if front { + if start == nil { + out.err = fmt.Errorf("cannot prepare a new conversation") + return done + } + out.replacement, out.err = start(where) + if out.err != nil { + return done + } + if out.replacement.Agent == nil { + out.err = fmt.Errorf("new conversation has no agent") + return done + } + } + if owner == nil && session.InUse(row.Transcript) { + if open == nil { + out.err = fmt.Errorf("conversation is held by another window") + return done + } + conv, err := open(row.Workspace, row.Transcript) + if err != nil { + out.err = err + return done + } + owner = conv.Agent + } + if owner != nil { + out.err = endConversationAgent(owner, session.StopByLeaving) + if out.err != nil { + if out.replacement.Agent != nil { + _ = out.replacement.Agent.Close() + out.replacement = Conversation{} + } + return done + } + out.stopped = true + } + out.err = session.DeleteConversation(row.Dir, row.ID) + return done + }) +} + +func (a *app) recordDeleted(msg recordDeletedMsg) tea.Cmd { + a.deleteBusy = false + cmd := a.keepTaskActionOwner(msg.attached) + if msg.err == nil { + if a.deletedRecords == nil { + a.deletedRecords = make(map[tasksKey]bool) + } + id := "" + if msg.task != nil { + id = msg.task.ID + } + a.deletedRecords[tasksKey{session: msg.row.ID, id: id}] = true + for _, taskID := range msg.taskIDs { + a.deletedRecords[tasksKey{session: msg.row.ID, id: taskID}] = true + } + a.reconcileDeletedTasks() + } + if msg.task == nil && (msg.stopped || msg.err == nil) { + key := msg.key + if msg.front && msg.replacement.Agent != nil { + a.forgetSteerOwner(draftOwnerOf(a.host, a.workspace, a.file)) + a.detachConversation() + cmd = a.attachConversation(msg.replacement, nil) + } else if held := a.behind[key]; held != nil { + a.letGoKept(key, held, func(Agent) {}) + } + a.forget(key) + delete(a.tabShut, key) + delete(a.unreadChats, key) + tabs := a.closedTabs[:0] + for _, tab := range a.closedTabs { + if tab.key != key { + tabs = append(tabs, tab) + } + } + a.closedTabs = tabs + visible := a.chatTabs[:0] + for _, tab := range a.chatTabs { + if tab.key != key { + visible = append(visible, tab) + } + } + a.chatTabs = visible + a.chatTabBar = tabBar{} + } + // Re-read both authorities so no cached task, close-stack entry or preview + // can bring a deleted conversation back to either list. + if msg.sessions && !a.at(pageTasks) { + cmd = tea.Batch(cmd, a.showPage(pageTasks)) + } + a.refreshRecordLists() + if msg.err != nil { + a.taskRowNotice("could not delete: " + msg.err.Error()) + } else if msg.task != nil { + a.taskRowNotice("task record deleted") + } else { + a.taskRowNotice("conversation deleted") + } + return cmd +} + +func (a *app) refreshRecordLists() { + if a.at(pageHome) { + a.refreshHome() + return + } + p := &a.taskSheet + p.world = a.readWorld() + p.mine = a.taskSheetMine() + p.reading = readTasks(p.world, p.mine, p.reading.win, p.order, p.reading.seen, a.now()) + p.cursor = a.tasksSettle(p.cursor) +} + +// conversationRowVerbs is shared by Home search and the Sessions tree. Enter +// already reopens, so the closed row's destructive action is explicitly Delete. +func (a *app) conversationRowVerbs(row session.SessionRow) []verb { + read := switcherRow{kind: switcherConversation, session: row, place: row.Workspace} + read.session.Archived = a.homeConversationClosed(row) + read.gone = a.home.gone[filepath.Clean(row.Workspace)] + return a.homeReadingVerbs(homeLine{kind: homeSession, row: row, dir: row.Workspace}, read) +} + +// keepTaskRecords rejects in-flight snapshots taken before a confirmed delete. +func (a *app) keepTaskRecords(rows []session.TaskIndexEntry) []session.TaskIndexEntry { + if len(a.deletedRecords) == 0 { + return rows + } + kept := make([]session.TaskIndexEntry, 0, len(rows)) + for _, row := range rows { + if !a.taskRecordDeleted(row.SessionID, row.ID, row.Parent, row.PlanID) { + kept = append(kept, row) + } + } + return kept +} + +// withoutDeletedConversations applies confirmed deletion to every world reading, +// including an engine snapshot captured before the delete finished. Copying the +// lists keeps the engine's shared cache immutable while both pages update now. +func (a *app) withoutDeletedConversations(world session.World) session.World { + for _, project := range world.Projects { + for _, row := range project.Sessions { + for id, gone := range row.DeletedTasks { + if gone { + if a.deletedRecords == nil { + a.deletedRecords = make(map[tasksKey]bool) + } + a.deletedRecords[tasksKey{session: row.ID, id: id}] = true + } + } + } + } + a.reconcileDeletedTasks() + if len(a.deletedRecords) == 0 { + return world + } + projects := make([]session.Project, 0, len(world.Projects)) + for _, project := range world.Projects { + rows := make([]session.SessionRow, 0, len(project.Sessions)) + for _, row := range project.Sessions { + if !a.deletedRecords[tasksKey{session: row.ID}] { + row.Tasks.Rows = a.keepTaskRecords(row.Tasks.Rows) + // Plan rows also read these tombstones when a stale world is returned. + deleted := make(map[string]bool) + for id, gone := range row.DeletedTasks { + deleted[id] = gone + } + for key, gone := range a.deletedRecords { + if key.session == row.ID && key.id != "" { + deleted[key.id] = gone + } + } + row.DeletedTasks = deleted + rows = append(rows, row) + } + } + project.Sessions = rows + projects = append(projects, project) + } + world.Projects = projects + return world +} diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 9095dfbb8..c0307632e 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -1155,7 +1155,7 @@ func (a *app) readWorldKnown() (session.World, bool) { Transcript: file, Title: a.title, Workspace: a.workspace, Model: a.model, }, time.Now()) } - return world, true + return a.withoutDeletedConversations(world), true } // worldOf is THE SEAM: the walk, and whether it is an answer. @@ -4833,6 +4833,9 @@ func homeHeadingWord(project string) string { // homeMark is which of the three kinds of row this is: the conversation on // screen, one this terminal is holding behind it, or somebody else's. func (a *app) homeMark(row session.SessionRow) rowMark { + if a.homeConversationClosed(row) { + return markNone + } switch { case row.Transcript == "": return markNone @@ -5495,6 +5498,8 @@ func conversationTokens(row session.SessionRow) int { func (a *app) homeHolding(row session.SessionRow) string { word := "" switch { + case a.homeConversationClosed(row): + word = "closed" case a.holding(row.Transcript): word = "open here" case row.Open || row.Live: diff --git a/internal/tui3/homebullets.go b/internal/tui3/homebullets.go index 51ccf235b..001f35ac6 100644 --- a/internal/tui3/homebullets.go +++ b/internal/tui3/homebullets.go @@ -17,7 +17,7 @@ import "github.com/Agent-Field/codeaf/internal/tui2/tokens" // saved rows, which carry `here` and no key — and a `/task` can be the first // thing it does. func (a *app) homeChatState(cell *homeCell) (working, unread bool) { - if cell == nil || cell.closed { + if cell == nil { return false, false } key := cell.chatKey diff --git a/internal/tui3/homebullets_test.go b/internal/tui3/homebullets_test.go index 534e8f2be..e8d1e28ba 100644 --- a/internal/tui3/homebullets_test.go +++ b/internal/tui3/homebullets_test.go @@ -103,8 +103,8 @@ func TestHomeConversationBulletsFollowAnswerAndUnreadState(t *testing.T) { t.Fatal("notification consumed unread state") } other.closed = true - if got := plain(a.homeConversationBullet(other, a.pal)); got != idle { - t.Fatal("closed conversation is not dim/idle") + if got := plain(a.homeConversationBullet(other, a.pal)); got != a.pal.glyph(tokens.GStepDone) { + t.Fatal("closing the tab hid its unread work") } } @@ -183,8 +183,12 @@ func TestHomeConversationReadsAnotherWindowsRunningTasks(t *testing.T) { t.Fatalf("another window's running task lost its working mark: %q", got) } cell.closed = true + if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GWorking) { + t.Fatalf("closing a conversation hid its running work: %q", got) + } + cell.row.session.Tasks.Running = 0 if got := plain(a.homeConversationBullet(cell, a.pal)); got != a.pal.glyph(tokens.GProseBullet) { - t.Fatalf("a closed conversation kept a working mark: %q", got) + t.Fatalf("a closed conversation kept a working mark after its work ended: %q", got) } } diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index 9edc8c89e..de9d1981c 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -194,7 +194,7 @@ func (a *app) homeStripInDescription(width, room int) bool { // homeDescriptionVerbs wraps whole choices within the description column. // It reads the same captured verbs as the inline strip, preserving their keys. func (a *app) homeDescriptionVerbs(width int, pal palette) []string { - return verbChoiceLines(a.strip.verbs, width, homeDescLeadBlank, pal) + return a.stripChoiceLines(width, homeDescLeadBlank, pal) } // homeDescNote is one row's note as the lines it takes: the thread's title diff --git a/internal/tui3/homeclose_test.go b/internal/tui3/homeclose_test.go new file mode 100644 index 000000000..ed6319ef9 --- /dev/null +++ b/internal/tui3/homeclose_test.go @@ -0,0 +1,211 @@ +package tui3 + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestHomeCloseKeepsProgressAndDeleteNeedsExplicitYes(t *testing.T) { + for _, width := range []int{50, 80, 120, 180} { + t.Run(fmt.Sprint(width), func(t *testing.T) { + a, files := homeTabsFixture(t) + a.width = width + worker := &fakeAgent{model: "m"} + watch := &behindWatch{} + watch.tasking.Store(true) + a.behind = map[string]*kept{a.convKey(files[1]): {conv: Conversation{Agent: worker, SessionFile: files[1]}, watch: watch}} + a.input.setText("keep the other draft") + a.home.point(files[1]) + drive(t, a, key("right"), key("x")) + if worker.closes != 0 || worker.stops != 0 { + t.Fatal("close interrupted work") + } + if !a.tabShut[a.convKey(files[1])] { + t.Fatal("close left the tab open") + } + a.home.point(files[1]) + line, ok := a.home.focusedLine() + if !ok || line.row.Transcript != files[1] { + t.Fatal("close hid the Home row") + } + if working, _ := a.homeChatState(line.cell); !working { + t.Fatal("closed task work no longer shows in progress") + } + drive(t, a, key("right"), key("x")) + if a.strip.prompt == "" { + t.Fatal("delete did not ask for confirmation") + } + if text := plain(strings.Join(a.verbStripRow(width), "\n")); !strings.Contains(text, "delete is permanent") || !strings.Contains(text, "y yes") || !strings.Contains(text, "n no") { + t.Fatalf("missing prompt: %s", text) + } + drive(t, a, key("enter")) + if a.strip.prompt == "" || worker.closes != 0 { + t.Fatal("Enter confirmed permanent deletion") + } + drive(t, a, key("n")) + if !a.strip.open || a.strip.prompt != "" || len(a.strip.verbs) != 4 { + t.Fatal("no did not return to the four actions") + } + if _, err := os.Stat(files[1]); err != nil { + t.Fatal("cancel deleted the conversation") + } + drive(t, a, key("x"), key("y")) + if worker.closes != 1 || worker.stops != 1 { + t.Fatalf("delete did not stop and close the agent: %+v", worker) + } + if _, err := os.Stat(filepath.Dir(files[1])); !os.IsNotExist(err) { + t.Fatalf("conversation folder remains: %v", err) + } + if a.input.String() != "keep the other draft" { + t.Fatal("deletion changed another draft") + } + a.refreshHome() + a.home.box.setText("Conversation 2") + a.home.build() + for _, line := range a.home.lines { + if line.kind == homeSession && line.row.Transcript == files[1] { + t.Fatal("deleted conversation reappeared in search") + } + } + for _, tab := range a.closedTabs { + if tab.file == files[1] { + t.Fatal("deleted conversation remains reopenable") + } + } + }) + } +} + +func TestSessionsConversationHasTheSameCloseDeleteAndCancelActions(t *testing.T) { + a, files := homeTabsFixture(t) + a.showPage(pageTasks) + selectChat := func() { + t.Helper() + for _, at := range a.taskSheet.stops(a) { + a.taskSheet.cursor = at + if chat, ok := a.taskSheetChat(); ok && chat.row.Transcript == files[1] { + return + } + } + t.Fatal("missing conversation in Sessions") + } + selectChat() + drive(t, a, key("right"), key("right")) + if !a.strip.open || len(a.strip.verbs) != 4 { + t.Fatal("Sessions does not expose four conversation actions") + } + drive(t, a, key("x")) + selectChat() + drive(t, a, key("right"), key("right"), key("x")) + if a.strip.prompt == "" { + t.Fatal("Sessions delete bypassed confirmation") + } + drive(t, a, key("n")) + if !a.strip.open || len(a.strip.verbs) != 4 { + t.Fatal("cancel did not restore Sessions actions") + } + drive(t, a, key("x"), key("y")) + if _, err := os.Stat(files[1]); !os.IsNotExist(err) { + t.Fatalf("Sessions did not delete: %v", err) + } +} + +func TestNestedTaskDeleteKeepsItsConversationAndSibling(t *testing.T) { + lab := newSwitchLab(t) + lab.task("-beta", session.TaskIndexEntry{ID: "root", SessionID: "bbbb000000000001", Title: "Parent task", Status: string(session.TaskDone), EndedAt: lab.now}) + lab.task("-beta", session.TaskIndexEntry{ID: "t1", Parent: "root", SessionID: "bbbb000000000001", Title: "read 40 filings", Status: string(session.TaskDone), EndedAt: lab.now}) + lab.task("-beta", session.TaskIndexEntry{Parent: "root", ID: "sibling", SessionID: "bbbb000000000001", Title: "Keep sibling", Status: string(session.TaskDone), EndedAt: lab.now}) + lab.task("-beta", session.TaskIndexEntry{ID: "child", Parent: "t1", SessionID: "bbbb000000000001", Title: "Child", Status: string(session.TaskDone), EndedAt: lab.now}) + lab.task("-beta", session.TaskIndexEntry{ID: "grandchild", Parent: "child", SessionID: "bbbb000000000001", Title: "Grandchild", Status: string(session.TaskDone), EndedAt: lab.now}) + a := lab.open(180, 60) + owner := selectHomeTask(t, a, "t1") + drive(t, a, key("right"), key("right"), key("x")) + if a.strip.prompt == "" { + t.Fatal("nested delete did not ask") + } + drive(t, a, key("n")) + if !a.strip.open || len(a.strip.verbs) != 4 { + t.Fatal("nested cancel did not restore actions") + } + drive(t, a, key("x"), key("y")) + if _, err := os.Stat(owner.Transcript); err != nil { + t.Fatalf("deleted the parent: %v", err) + } + meta, _ := session.LoadMeta(owner.Dir) + if meta.DeletedTasks["sibling"] { + t.Fatal("task deletion included a sibling") + } + if !meta.DeletedTasks["t1"] || !meta.DeletedTasks["child"] || !meta.DeletedTasks["grandchild"] { + t.Fatal("task deletion was not permanent") + } + for _, item := range a.tasksFiltered().items { + if item.entry.SessionID == owner.ID && (item.entry.ID == "t1" || item.entry.ID == "child" || item.entry.ID == "grandchild") { + t.Fatal("deleted task reappeared") + } + } + records := session.ReadTaskIndex(session.TaskIndexPath(owner.Transcript)) + foundSibling := false + for _, entry := range records { + if entry.SessionID == owner.ID && entry.ID == "sibling" { + foundSibling = true + } + } + if !foundSibling { + t.Fatal("sibling record was deleted") + } + fresh := lab.open(180, 60) + fresh.showPage(pageTasks) + for _, item := range fresh.tasksFiltered().items { + if item.entry.SessionID == owner.ID && item.entry.ID == "t1" { + t.Fatal("fresh scan resurrected deleted task") + } + } +} + +func TestDeleteCurrentConversationInstallsANewOwner(t *testing.T) { + a, files := homeTabsFixture(t) + old := &fakeAgent{model: "m"} + a.agent = old + next := &fakeAgent{model: "m"} + a.start = func(string) (Conversation, error) { + return Conversation{Agent: next, SessionFile: files[5], Workspace: a.workspace}, nil + } + a.home.point(files[0]) + drive(t, a, key("right"), key("x")) + a.home.point(files[0]) + drive(t, a, key("right"), key("x"), key("y")) + if a.agent != next || a.file != files[5] || old.closes != 1 { + t.Fatal("foreground deletion did not replace its owner") + } + if _, err := os.Stat(files[0]); !os.IsNotExist(err) { + t.Fatalf("foreground history remains: %v", err) + } + if !a.at(pageHome) { + t.Fatal("delete left Home") + } +} + +func TestDeletedConversationRejectsAnOlderTaskSnapshot(t *testing.T) { + a, files := homeTabsFixture(t) + stale := session.TaskIndexEntry{SessionID: filepath.Base(filepath.Dir(files[1])), ID: "1", Title: "Old cached work"} + a.comp.tasks = []session.TaskIndexEntry{stale} + a.home.point(files[1]) + drive(t, a, key("right"), key("x")) + a.home.point(files[1]) + drive(t, a, key("right"), key("x"), key("y")) + drive(t, a, tasksLoadedMsg{rows: []session.TaskIndexEntry{stale}, known: true}) + if len(a.comp.tasks) != 0 { + t.Fatal("late index response restored the deleted conversation's task") + } + a.showPage(pageTasks) + for _, item := range a.tasksFiltered().items { + if item.entry.SessionID == stale.SessionID { + t.Fatal("deleted conversation reappeared in Sessions") + } + } +} diff --git a/internal/tui3/homepanel_left.go b/internal/tui3/homepanel_left.go index 4ec9bcf0e..dd2983f1b 100644 --- a/internal/tui3/homepanel_left.go +++ b/internal/tui3/homepanel_left.go @@ -112,7 +112,7 @@ const ( func ledgerLanded(world session.World, seen time.Time) []switcherRow { var out []switcherRow for _, landed := range session.LandedSince(&world, seen) { - if landed.Entry.Parent != "" || landed.Session.ArchivedTasks[landed.Entry.ID] { + if landed.Entry.Parent != "" { continue } entry := landed.Entry diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index e3be91537..09175c4da 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -299,7 +299,7 @@ func needsCallOf(row session.SessionRow, entry session.TaskIndexEntry) (session. // WORK IN A CONVERSATION SOMEBODY PUT AWAY IS NOT WAITING ON THEM. Archiving // is the decision to stop being asked about it, and every other panel already // reads it that way ([machineCounts] skips an archived row outright). - if row.Archived || row.ArchivedTasks[entry.ID] { + if row.Archived { return session.TaskStatus{}, false } status := taskEntryStatus(entry, row.Runs(entry)) diff --git a/internal/tui3/homepanel_running.go b/internal/tui3/homepanel_running.go index 517106092..d8871894a 100644 --- a/internal/tui3/homepanel_running.go +++ b/internal/tui3/homepanel_running.go @@ -37,13 +37,12 @@ func (a *app) openTaskDoor(entry *session.TaskIndexEntry) tea.Cmd { // ── stopping one ──────────────────────────────────────────────────────────── -// runningVerbs combines task row options with the stop action when this window -// holds the running task. Putting work away changes visibility, never execution; -// stopping still uses the existing confirmation card. +// runningVerbs gives task rows the same Stop/Delete actions as Sessions. +// Other running work retains its existing stop confirmation. func (a *app) runningVerbs(line homeLine) []verb { var verbs []verb if line.cell != nil && line.cell.row != nil && line.cell.row.task != nil { - verbs = a.taskRowVerbs(line.cell.row.session, *line.cell.row.task) + return a.taskRowVerbs(line.cell.row.session, *line.cell.row.task) } target := a.runningStopTarget(line) if target.empty() { diff --git a/internal/tui3/hop.go b/internal/tui3/hop.go index 07df20e0a..8c564462d 100644 --- a/internal/tui3/hop.go +++ b/internal/tui3/hop.go @@ -355,14 +355,21 @@ func (a *app) hopMayOpen() bool { return !a.composer.open && !a.copy.on } func (a *app) hopOpen() { // A shared engine handle can only have one open conversation. Show its // other saved chats immediately; an open-only list would offer no choice. - rows, tabs, rest := a.hopReading(a.shared) - if len(rows) < 2 && rest == 0 { - // Nowhere to go. The guard above has already refused this, and this is - // the same refusal said where the rows are actually counted. + all := a.shared + rows, tabs, rest := a.hopReading(all) + // With every tab closed, the fold is the whole list. Expand it before + // raising the card so the keyboard never belongs to an invisible menu. + if len(rows) == 0 && rest > 0 { + all = true + rows, tabs, rest = a.hopReading(all) + } + if rest == 0 && (len(rows) == 0 || len(rows) == 1 && tabs == 1 && rows[0].here) { + // A lone current tab offers no destination, while a closed row can + // still be reopened even when it is the only saved conversation. return } a.dropHover() - a.hop = hopCard{open: true, all: a.shared, rows: rows, rest: rest, tabs: tabs, total: len(rows) + rest, at: a.hopFirstStop(rows), armed: -1, from: a.file} + a.hop = hopCard{open: true, all: all, rows: rows, rest: rest, tabs: tabs, total: len(rows) + rest, at: a.hopFirstStop(rows), armed: -1, from: a.file} a.touch() } @@ -383,6 +390,10 @@ func (a *app) hopSpread(all bool) { a.dropHover() at := a.hop.at rows, tabs, rest := a.hopReading(all) + // Hiding the only rows would erase the card without dismissing it. + if len(rows) == 0 { + return + } a.hop.rows, a.hop.rest, a.hop.tabs, a.hop.all, a.hop.armed, a.hop.say = rows, rest, tabs, all, -1, "" a.hop.at = min(at, max(0, len(rows)-1)) // AND THE CURSOR LEAVES `you are here` THE MOMENT THERE IS SOMEWHERE ELSE TO @@ -633,9 +644,6 @@ func (a *app) hopRest(open []hopRow, now time.Time) []hopRow { var all []switcherRow for _, project := range world.Projects { for _, row := range project.Sessions { - if row.Archived { - continue - } a.hopKnown++ if held[a.convKey(row.Transcript)] { continue @@ -1482,7 +1490,7 @@ func (a *app) hopFoot() string { return a.hop.say } switch { - case a.hop.all: + case a.hop.all && a.hop.tabs > 0: return hopShutKeyWord case a.hop.rest > 0: return hopFoldKeyWord @@ -1795,11 +1803,7 @@ func (a *app) countConversations() tea.Cmd { } n := 0 for _, project := range seen.Projects { - for _, row := range project.Sessions { - if !row.Archived { - n++ - } - } + n += len(project.Sessions) } return hopCountMsg{n: n} } diff --git a/internal/tui3/keeper.go b/internal/tui3/keeper.go index 7c90119d6..c85188ecc 100644 --- a/internal/tui3/keeper.go +++ b/internal/tui3/keeper.go @@ -886,7 +886,7 @@ func (a *app) stow(conv Conversation, side *aside) tea.Cmd { if a.shared { a.stowDrafts(conv, side) // Retain the outgoing navigation identity even though its agent ended. - a.rememberOpen(key) + a.rememberHeld(key) return nil } // AND IT IS THE WHOLE COMPOSER, not only the box: every page's own unsent line @@ -903,7 +903,7 @@ func (a *app) stow(conv Conversation, side *aside) tea.Cmd { watch: startBehindWatch(key, conv.Agent, a.stirs), } a.behind[key] = held - a.rememberOpen(key) + a.rememberHeld(key) var parked tea.Cmd if side != nil && len(side.parks) > 0 { // The turn may have ended between the park and the watcher joining. Ask @@ -920,6 +920,17 @@ func (a *app) stow(conv Conversation, side *aside) tea.Cmd { return parked } +// rememberHeld updates recency without reopening a dismissed tab when its +// conversation moves from the foreground into the keeper. +func (a *app) rememberHeld(key string) { + if a.tabShut[key] { + a.forget(key) + a.prev = append(a.prev, key) + return + } + a.rememberOpen(key) +} + // rememberOpen puts a key on top of the previous-stack, which is the order `tab` // walks and the order [app.closeFront] brings a conversation forward in. // @@ -928,6 +939,9 @@ func (a *app) stow(conv Conversation, side *aside) tea.Cmd { // and a stack that grew an entry per visit would send `tab` somewhere it has // already been. func (a *app) rememberOpen(key string) { + if err := a.saveConversationClosed(key, false); err != nil { + a.taskRowNotice("could not save reopened conversation: " + err.Error()) + } a.forget(key) a.prev = append(a.prev, key) // AND A CONVERSATION COMING FORWARD GETS ITS TAB BACK. This is the one door @@ -935,7 +949,7 @@ func (a *app) rememberOpen(key string) { // a close bringing the next one up — so a dismissal lifted here cannot be // missed by a road somebody adds later (chattabs.go's [app.tabDismiss]). if a.tabShut[key] { - delete(a.tabShut, key) + a.tabShut[key] = false a.chatTabBar = tabBar{} } if a.at(pageHome) && a.home.tabs != nil { @@ -1190,12 +1204,18 @@ func (a *app) endAgent(agent Agent) { a.endAgentFor(agent, session.StopByLeaving // CONVERSATION to go, not for the reply to be thrown away — so the engine owes // them a sentence about the answer that never came. func (a *app) endAgentFor(agent Agent, door session.StopDoor) { - agent.InterruptFor(door) - if err := agent.Close(); err != nil { + if err := endConversationAgent(agent, door); err != nil { a.note("close failed: " + err.Error()) } } +// endConversationAgent shares the shutdown order with confirmed deletion and +// returns failure before the caller removes any saved conversation files. +func endConversationAgent(agent Agent, door session.StopDoor) error { + agent.InterruptFor(door) + return agent.Close() +} + // leaveFront is the act both doors above are: the conversation in front is let // go of, and the most recently open one comes forward. // diff --git a/internal/tui3/pages.go b/internal/tui3/pages.go index bb3ef7b50..d575f9128 100644 --- a/internal/tui3/pages.go +++ b/internal/tui3/pages.go @@ -1649,6 +1649,9 @@ func (a *app) placeHintSaid() string { return line } if a.strip.open { + if a.strip.prompt != "" { + return "y yes · n no · esc cancel" + } return stripHint } // AND A LAYER INSIDE A PLACE OUTRANKS THE ROUTER'S TAIL for exactly the diff --git a/internal/tui3/pages_test.go b/internal/tui3/pages_test.go index 6c4470cd9..f4ad17992 100644 --- a/internal/tui3/pages_test.go +++ b/internal/tui3/pages_test.go @@ -382,7 +382,7 @@ func TestTheArrowOnlyOpensAStripWhereTheRowHasVerbs(t *testing.T) { t.Fatalf("an addressless row offered %v", got) } bare.session.Archived = true - if got := switcherVerbsFor(bare); len(got) != 2 || got[1].key != 'c' || got[0].key != 'x' || got[0].word != "reopen" { + if got := switcherVerbsFor(bare); len(got) != 2 || got[1].key != 'c' || got[0].key != 'x' || got[0].word != "delete" { t.Fatalf("an archived row offered %v", got) } } diff --git a/internal/tui3/place_home.go b/internal/tui3/place_home.go index a204b8390..047a88919 100644 --- a/internal/tui3/place_home.go +++ b/internal/tui3/place_home.go @@ -195,6 +195,9 @@ func (a *app) homeRowVerbs() []verb { // thing on it with verbs — the two actions home has been ADVERTISING on such // a row without binding (`homeItemActions`, homestanding.go), bound to ctrl+e // and ctrl+x, which the line never named, and whose bare `p` and `s` typed. + if line.kind == homeSession { + return a.conversationRowVerbs(line.row) + } if !line.standsForItem() { return nil } @@ -209,6 +212,9 @@ func (a *app) homeRowVerbs() []verb { func (a *app) homeReadingVerbs(line homeLine, row switcherRow) []verb { var verbs []verb for _, v := range switcherVerbsFor(row) { + if a.hosted() && v.key == 'x' && a.homeConversationClosed(row.session) { + continue + } if a.hosted() && row.kind == switcherConversation && v.answer == "" && (v.key == 'o' || v.key == 'n') { continue } @@ -221,8 +227,8 @@ func (a *app) homeReadingVerbs(line homeLine, row switcherRow) []verb { if row.gone && v.answer == "" && (v.key == 'n' || v.key == 'o') { continue } - if v.key == 'x' && row.kind == switcherConversation && (row.session.Archived || line.cell != nil && line.cell.closed) { - v.word = "reopen" + if v.key == 'x' && row.kind == switcherConversation && (a.homeConversationClosed(row.session) || line.cell != nil && line.cell.closed) { + v.word = "delete" } verbs = append(verbs, a.homeSwitchVerb(line, row, v)) } @@ -248,7 +254,12 @@ func (a *app) homeSwitchVerb(line homeLine, row switcherRow, v switcherVerb) ver return cmd } case v.key == 'x': - do = func() tea.Cmd { return a.homeArchiveRow(row.session) } + do = func() tea.Cmd { + if a.homeConversationClosed(row.session) { + return a.askRecordDelete(row.session, nil) + } + return a.homeArchiveRow(row.session) + } case v.key == 'n': do = func() tea.Cmd { return a.homeStartInProject(homeWhere(line)) } case v.key == 'o': @@ -277,9 +288,9 @@ func (a *app) homeArchiveRow(row session.SessionRow) tea.Cmd { if row.Transcript == a.file { key = a.frontTabKey() } - a.tabShutKey(key) - a.home.say(homeClosedWord, "") - a.refreshHome() + a.tabShutSaved(key) + a.taskRowNotice(homeClosedWord) + a.refreshRecordLists() return nil } @@ -288,7 +299,10 @@ func (a *app) homeConversationClosed(row session.SessionRow) bool { if row.Transcript == a.file { key = a.frontTabKey() } - return row.Archived || a.tabShut[key] + if closed, known := a.tabShut[key]; known { + return closed + } + return row.Archived } // A new tab without a saved session has no archive record yet. diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index 331a340a8..2ee4320b9 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -51,16 +51,6 @@ type tasksPlace struct { // rather than deleted, which is the whole reason this map is read as // presence-and-value instead of as a set. opened map[tasksKey]bool - // closed holds tasks put away during this search. A close must remove its - // row immediately even though searches can recover older archived tasks. - // Editing the query starts a new search and makes them discoverable again. - closed map[tasksKey]bool - // closedQuery is the search text [tasksPlace.closed] was put away under. - // AN EDIT THAT CHANGES NOTHING IS NOT A NEW SEARCH: ctrl+k at the end of the - // box, or backspace with the caret at its start, takes no rune, and a task - // that came back on a keystroke that left the text as it was would read as a - // close that did not hold. - closedQuery string // query is the type-to-filter box, and it is the [editor] every other box on // this surface is rather than a string of its own: backspace, ctrl+u and // ctrl+w are edits a person's hands already know, and a second implementation @@ -342,6 +332,15 @@ func (p *tasksPlace) lineOf(a *app, want tasksKey) (int, bool) { func (p *tasksPlace) filtered(a *app) tasksReading { r := p.reading r.chatViews = make(map[string]tasksChatView) + // The window's close state is newer than a background world reading. Keep + // the activity marks independent: a closed tab may still have running work. + for _, row := range r.chats { + cell := &homeCell{chatKey: a.convKey(row.Transcript)} + working, unread := a.homeChatState(cell) + _, moving := homeMovingAt(homeLine{kind: homeSession, row: row}) + working = working || moving + r.chatViews[row.Transcript] = tasksChatView{title: homeName(row), working: working, unread: unread, closed: a.homeConversationClosed(row)} + } for _, tab := range a.tabList() { if tab.work { continue @@ -357,30 +356,10 @@ func (p *tasksPlace) filtered(a *app) tasksReading { // ([tasksControlRow]) and a row cannot ask the surface anything. It is the // untrimmed text, so a person who has typed a space sees the caret move. r.query = p.query.String() - if needle == "" || len(p.closed) > 0 { - var kept []tasksItem - for i, item := range r.items { - if p.closed[tasksKeyOf(item.entry)] || (needle == "" && item.row.ArchivedTasks[item.entry.ID]) { - if kept == nil { - kept = make([]tasksItem, 0, len(r.items)) - kept = append(kept, r.items[:i]...) - } - continue - } - if kept != nil { - kept = append(kept, item) - } - } - if kept != nil { - r.items = kept - tree := tasksTreeOf(kept, r.now, r.order, r.chats...) - tree.keepConversationStates(p.reading.tree()) - r.shape = &tree - } - } if needle == "" { return r } + // A QUERY OPENS EVERY FOLD ON THE PAGE. A row that matched and is sitting // behind a shut fold is a row the query appears not to have found, and the // fold somebody left shut is not a decision they made about a list they had @@ -487,9 +466,11 @@ func (a *app) taskSheetSelfRow() session.SessionRow { Transcript: strings.TrimSpace(a.file), Model: a.model, Workspace: a.workspace, + Owned: a.owned, } if file := row.Transcript; file != "" { - row.ID = filepath.Base(filepath.Dir(file)) + row.Dir = filepath.Dir(file) + row.ID = filepath.Base(row.Dir) } row.Open, row.Live = true, true row.Presence.State = a.taskSheetSelfState() @@ -577,7 +558,7 @@ func (a *app) taskSheetOwnRows() []session.TaskIndexEntry { Program: a.nodeProgram(node), }) } - return rows + return a.keepTaskRecords(rows) } // taskIndexHolds reports whether the project's index already carries this node. @@ -849,9 +830,6 @@ func (a *app) taskSheetReverseAge() { // the window with it. A cursor left at row forty of a list that now has three is // a page a person types one letter into and finds empty. func (a *app) taskSheetTyped() { - if a.taskSheet.query.String() != a.taskSheet.closedQuery { - a.taskSheet.closed = nil - } a.taskSheet.top = 0 a.taskSheet.cursor = a.tasksSettle(0) } @@ -1633,43 +1611,17 @@ func (a *app) taskSheetKeysLine() string { return a.taskSheet.hint(a) } // of the foot's second clause: the strip draws exactly what the foot named, and // the foot names exactly what the strip will do. // -// ONLY ONE OF SCREEN 1e's TWO VERBS EXISTS, and the other is therefore ABSENT -// rather than drawn dead. The design spells `run it again, stop it`: -// -// - `stop it` is real. A node THIS window's graph is holding, still queued or -// running, is exactly what [app.stopTaskTarget] offers the roster's own `x`, -// and the engine door behind it is [app.stopDoors]. Work another conversation -// ran has no such node — the id in a cancel address is this session's — and -// work that has settled has nothing left to stop, so neither is offered one. -// - `run it again` has NO SEAM. Nothing on this machine re-runs a finished -// task: a record row is an account of work that happened, and starting the -// same brief again is `/task `, which is a new piece of work with a -// new id rather than a repeat of an old one. A capability that cannot work is -// absent, not broken — so the verb is not named here, and the foot does not -// promise it. +// Conversations and nested tasks share Home's four actions. Tasks stop while +// active and offer confirmed subtree deletion once all their work has stopped. func (p *tasksPlace) verbs(a *app) []verb { + if chat, ok := a.taskSheetChat(); ok { + return a.conversationRowVerbs(chat.row) + } item, ok := a.taskSheetCurrent() if !ok { return nil } - verbs := a.taskRowVerbs(item.row, item.entry) - entry := item.entry - node := a.taskSheetNodeFor(&entry) - if node == nil { - return verbs - } - target := a.stopTaskTarget(node) - if target.empty() { - return verbs - } - // THE BUILD GUARD, ASKED BEFORE THE VERB IS NAMED. A surface driven by an - // agent with no door onto cancelling says so when `x` is pressed - // ([stopUnavailableWord]); a NAMED verb that could only ever answer with that - // sentence would be this place advertising a key it has not got. - if _, ok := a.stopDoors(); !ok { - return verbs - } - return append(verbs, verb{key: 's', word: stopActWord, do: func() tea.Cmd { return a.tasksStop(target) }}) + return a.taskRowVerbs(item.row, item.entry) } // tasksStop ends one piece of work from the strip, and says what the engine @@ -1723,7 +1675,7 @@ func (p *tasksPlace) changed(a *app, since time.Time) int { for _, project := range world.Projects { for _, row := range project.Sessions { for _, entry := range row.Tasks.Rows { - if !row.ArchivedTasks[entry.ID] && !entry.EndedAt.IsZero() && entry.EndedAt.After(since) { + if !entry.EndedAt.IsZero() && entry.EndedAt.After(since) { count++ } } @@ -1815,8 +1767,8 @@ func (placeTasks) verbs(a *app) []verb { return a.taskSheet.verbs(a) } func (placeTasks) rowID(a *app) string { if chat, ok := a.taskSheetChat(); ok { // A CONVERSATION IS NAMED SO IT CANNOT BE MISTAKEN FOR THE WORK UNDER IT - // ([tasksChatKey] holds the mark that makes that true). It carries no verbs - // of its own, and a strip captured over a task must not survive the cursor + // ([tasksChatKey] holds the mark that makes that true). A strip captured + // over a task must not survive the cursor // stepping onto the chat above it. return chat.key.session + "\x00" + chat.key.id } diff --git a/internal/tui3/place_tasks_test.go b/internal/tui3/place_tasks_test.go index 238beeed6..f460bdf5c 100644 --- a/internal/tui3/place_tasks_test.go +++ b/internal/tui3/place_tasks_test.go @@ -54,7 +54,7 @@ func TestTheTasksFootIsScreenOneEWordForWord(t *testing.T) { // sorting is a chord, and a chord nobody can find is a chord that does not // exist. The filter is named beside it because nothing else on the frame says // that a letter goes into the box on the control row rather than to the page. - const want = "enter open its room · → verbs: close, open folder, copy project, stop it · type to filter · esc close" + const want = "enter open its room · → verbs: stop, open folder, copy project · type to filter · esc close" if got := a.taskSheetKeysLine(); got != want { t.Fatalf("the foot reads\n %q\nwant\n %q", got, want) } @@ -120,10 +120,10 @@ func TestTheTasksVerbsOnTheStripAreTheVerbsInTheFoot(t *testing.T) { for _, v := range verbs { keys += string(v.key) } - if keys != "xops" { - t.Fatalf("the running row offers %q, want archive, folder, copy and stop", keys) + if keys != "xop" { + t.Fatalf("the running row offers %q, want stop, folder and copy", keys) } - if !strings.Contains(a.taskSheetKeysLine(), stopActWord) { + if !strings.Contains(a.taskSheetKeysLine(), "stop") { t.Fatalf("the foot does not name the verb the row has: %q", a.taskSheetKeysLine()) } @@ -133,7 +133,7 @@ func TestTheTasksVerbsOnTheStripAreTheVerbsInTheFoot(t *testing.T) { t.Fatal("→ on a row with a verb opened no strip") } strip := plain(strings.Join(a.verbStripRow(a.width), "\n")) - if !strings.Contains(strip, "s "+stopActWord) { + if !strings.Contains(strip, "x stop") { t.Fatalf("the strip does not draw the verb: %q", strip) } } @@ -214,32 +214,15 @@ func TestTheTasksFootNamesNoStopWithoutTheEnginesDoor(t *testing.T) { } } -// `s` ON THE STRIP ENDS THE WORK, through the door the card uses and with the -// engine's own sentence kept. -func TestSOnTheTasksStripStopsThatTaskThroughTheEnginesDoor(t *testing.T) { +// Closing and deleting records must not grow an unadvertised stop shortcut. +func TestSOnTheTasksStripDoesNotStopWork(t *testing.T) { a, agent := tasksFootApp(t) - agent.line = "stopping task 7 — its branch is kept" - - drive(t, a, tea.KeyPressMsg{Code: tea.KeyRight}) - drive(t, a, key("s")) - - if len(agent.asked) != 1 || agent.asked[0] != session.CancelTask+":7" { - t.Fatalf("the engine was asked %v, want one %q", agent.asked, session.CancelTask+":7") - } - // The strip goes away with the row it was about, and the engine's line is - // where the person is looking. - if a.strip.open { - t.Fatal("the verb left its strip standing") - } - if text := taskText(a); !strings.Contains(text, "stopping task 7") { - t.Fatalf("the engine's own sentence is nowhere:\n%s", text) - } - // AND NO CONFIRMATION CARD WAS RAISED, because stop.go refuses to raise one - // over a frame that is not drawing it — the strip is the deliberate gesture - // here, and `→` then `s` is two presses with the verb on screen for the - // second of them. - if a.stopping() { - t.Fatal("the strip raised a card this place cannot draw") + drive(t, a, key("right"), key("s")) + if len(agent.asked) != 0 { + t.Fatal("an unadvertised key stopped work") + } + if !a.strip.open { + t.Fatal("an unadvertised key dismissed the actions") } } diff --git a/internal/tui3/placemsgline_test.go b/internal/tui3/placemsgline_test.go index 5fa396bd6..a38b22f4a 100644 --- a/internal/tui3/placemsgline_test.go +++ b/internal/tui3/placemsgline_test.go @@ -67,13 +67,13 @@ func TestAWaitingQuestionRidesBehindTheRowDoorOnOneLine(t *testing.T) { } // TestANotelessFootKeepsTheDoorHintAsToday is the control: a task that raised -// nothing draws the foot it always drew, unchanged. +// nothing keeps its door and the actions that fit beside it. func TestANotelessFootKeepsTheDoorHintAsToday(t *testing.T) { a, _ := tasksFootApp(t) a.width, a.height = 100, 30 line := msgFootLine(a) - if line != "enter open its room · tab next place · esc close" { - t.Fatalf("a foot with nothing to say changed anyway:\n %q\nwant\n %q", line, "enter open its room · tab next place · esc close") + if line != "enter open its room · → verbs: stop, open folder, copy project · tab next place · esc close" { + t.Fatalf("a foot with nothing to say changed anyway:\n %q\nwant\n %q", line, "enter open its room · → verbs: stop, open folder, copy project · tab next place · esc close") } if strings.Contains(line, "waiting in this conversation") { t.Fatalf("a task that raised nothing drew a waiting note: %q", line) diff --git a/internal/tui3/programbadge_test.go b/internal/tui3/programbadge_test.go index 7dd6b6b4a..dd9904cf6 100644 --- a/internal/tui3/programbadge_test.go +++ b/internal/tui3/programbadge_test.go @@ -448,8 +448,8 @@ func TestTheOtherListsOfTheWorkWearTheBadge(t *testing.T) { t.Fatalf("an ordinary @ row is %q", label) } - withBadge := plain(tasksTableRow(" ", 2, programTitle, "senior-dev", rowSay("running"), rowSay("3m"), a.pal.dim, 120, tasksByAge, a.pal, false, "", "")) - without := plain(tasksTableRow(" ", 2, programTitle, "", rowSay("running"), rowSay("3m"), a.pal.dim, 120, tasksByAge, a.pal, false, "", "")) + withBadge := plain(tasksTableRow(" ", 2, programTitle, "senior-dev", rowSay("running"), rowSay("3m"), a.pal.dim, 120, tasksByAge, a.pal, false, false, "", "")) + without := plain(tasksTableRow(" ", 2, programTitle, "", rowSay("running"), rowSay("3m"), a.pal.dim, 120, tasksByAge, a.pal, false, false, "", "")) if !strings.Contains(withBadge, programTitle+" [senior-dev]") { t.Fatalf("the tasks place's row is %q", withBadge) } diff --git a/internal/tui3/roomrefusal.go b/internal/tui3/roomrefusal.go index a3555640f..ed8575b73 100644 --- a/internal/tui3/roomrefusal.go +++ b/internal/tui3/roomrefusal.go @@ -3,6 +3,7 @@ package tui3 import ( "strings" + "github.com/Agent-Field/codeaf/internal/session" "github.com/charmbracelet/x/ansi" ) @@ -107,6 +108,10 @@ func (r refusal) fit(width int) string { // The refusals themselves, spelled once each. var ( + // A stopped task keeps its ending when its page refuses further input. + roomStoppedRefusal = refusal{what: "this task was stopped", shortWhat: "stopped", door: refusalMainDoor} + roomGuestStoppedRefusal = refusal{what: "this task was stopped", shortWhat: "stopped", door: refusalOwnerLead + refusalOwnerPlace} + // roomFinishedRefusal is what a landed node's room says — at its foot, and in // the box's own placeholder. The parent clause is added by // [app.roomFinishedRefusal] when there is a parent to name. @@ -145,6 +150,8 @@ var ( // question drawing three doors as pressable keys (room.go's [app.guardRows]), // which is this law's own best case rather than an exception to it. var taskRefusals = []refusal{ + roomStoppedRefusal, + roomGuestStoppedRefusal, roomFinishedRefusal, roomUnavailableRefusal, roomGuestFinishedRefusal, @@ -165,6 +172,9 @@ var taskRefusals = []refusal{ // offered as a door onto a name nobody has. func (a *app) roomFinishedRefusal() refusal { out := roomFinishedRefusal + if a.taskStatus(a.roomNode()).Presence == session.TaskPresenceStopped { + out = roomStoppedRefusal + } if title := a.roomParentTitle(); title != "" { out.door += refusalParentDoor + title } @@ -185,6 +195,9 @@ func (a *app) roomDoneRefusal() refusal { return a.roomFinishedRefusal() } out := roomGuestFinishedRefusal + if a.taskStatus(a.roomNode()).Presence == session.TaskPresenceStopped { + out = roomGuestStoppedRefusal + } if owner := strings.TrimSpace(guest.owner); owner != "" { out.door = refusalOwnerLead + owner } diff --git a/internal/tui3/stoppedroom_test.go b/internal/tui3/stoppedroom_test.go new file mode 100644 index 000000000..2b7848ad8 --- /dev/null +++ b/internal/tui3/stoppedroom_test.go @@ -0,0 +1,50 @@ +package tui3 + +import ( + "github.com/Agent-Field/codeaf/internal/session" + "strings" + "testing" +) + +func TestConversationStoppedTaskNeverSaysFinished(t *testing.T) { + a := statusApp(t) + for _, stopped := range []bool{true, false} { + a.dropTasks() + notice := session.TaskNotice{ID: 17, Title: "Session stop", State: session.TaskRunning} + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: ¬ice}) + notice.State = session.TaskDone + if stopped { + notice.State = session.TaskFailed + notice.Stopped = true + notice.Ending = session.TaskEndingStopped + } + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: ¬ice}) + a.room = &taskRoom{id: 17} + want := "done" + if stopped { + want = "stopped" + } + if got := a.taskStatus(a.tasks[17]).Word; got != want { + t.Fatalf("task bar says %q, want %q", got, want) + } + for _, line := range []string{a.roomFinishedRefusal().line(), a.roomDoneRefusal().line()} { + if stopped && (!strings.Contains(line, "stopped") || strings.Contains(line, "finished")) { + t.Fatalf("stopped room says %q", line) + } + if !stopped && !strings.Contains(line, "finished") { + t.Fatalf("completed room says %q", line) + } + } + // An identical notice cannot change the meaning of the ending. + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: ¬ice}) + if got := a.taskStatus(a.tasks[17]).Word; got != want { + t.Fatalf("repeat notice changed status to %q", got) + } + a.room.guest = &taskGuest{node: a.tasks[17], owner: "Another conversation"} + guestLine := a.roomDoneRefusal().line() + if stopped && (!strings.Contains(guestLine, "stopped") || strings.Contains(guestLine, "finished")) { + t.Fatalf("guest stopped page says %q", guestLine) + } + a.room = nil + } +} diff --git a/internal/tui3/switcher.go b/internal/tui3/switcher.go index c10f7d773..df2eee227 100644 --- a/internal/tui3/switcher.go +++ b/internal/tui3/switcher.go @@ -527,7 +527,7 @@ func switcherVerbsFor(row switcherRow) []switcherVerb { verbs := switcherQuestionVerbs(row.options) word := "close" if row.session.Archived { - word = "reopen" + word = "delete" } verbs = append(verbs, switcherVerb{key: 'x', word: word}, switcherVerb{key: 'c', word: "copy name"}) if strings.TrimSpace(row.session.Workspace) != "" || strings.TrimSpace(row.session.ProjectDir) != "" { diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 4a6e3431d..f27db129f 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -182,6 +182,7 @@ type taskNode struct { // why the parent is a string. Empty and false is a session that has never // run anything adaptive, which is every session until one does. parent string + planID string paused bool // run and node name this row's door inside an adaptive run. A root carries // only run; a child carries both, so the same door opens the run's page at @@ -1245,7 +1246,7 @@ func (a *app) refuseFormingCard() { // the question as well — it is the question that draws the clock now. func (a *app) proposeTask(ev session.Event) { notice := ev.Task - if notice == nil { + if notice == nil || a.taskNoticeDeleted(notice) { return } // A PROPOSAL THIS WINDOW HAS ALREADY SEEN IS AN UPDATE, NEVER A SECOND @@ -5190,7 +5191,7 @@ func (a *app) railJoin(text, rail string) string { // was accepted is the record of it. func (a *app) taskUpdate(ev session.Event) tea.Cmd { notice := ev.Task - if notice == nil { + if notice == nil || a.taskNoticeDeleted(notice) { return nil } // A BACKGROUND JOB IS NOT A TASK AND IS NOT FILED AS ONE. Jobs arrive on @@ -5200,6 +5201,9 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { if notice.Kind == session.TaskKindJob { return nil } + if node := a.tasks[notice.ID]; node != nil && notice.PlanID != "" { + node.planID = notice.PlanID + } if last, seen := a.taskSeen[notice.ID]; seen && last == notice.State { // THE DE-DUP HAS EXCEPTIONS, and every one of them is news that arrives // without a state change. The pair above catches the same update arriving @@ -5302,6 +5306,7 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { // wore even when the notice that made it said nothing about it. node.program = card.program } + node.planID = notice.PlanID a.tasks[notice.ID] = node a.taskOrder = append(a.taskOrder, notice.ID) } diff --git a/internal/tui3/taskdeletion.go b/internal/tui3/taskdeletion.go new file mode 100644 index 000000000..0cc657361 --- /dev/null +++ b/internal/tui3/taskdeletion.go @@ -0,0 +1,98 @@ +package tui3 + +import ( + "path/filepath" + "strconv" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// taskRecordDeleted gives every projection the same owner-scoped decision. +// Permanent deletion wins over snapshots and notices already in flight. +func (a *app) taskRecordDeleted(owner string, ids ...string) bool { + if a.deletedRecords[tasksKey{session: owner}] { + return true + } + for _, id := range ids { + if id != "" && a.deletedRecords[tasksKey{session: owner, id: id}] { + return true + } + } + return false +} + +func (a *app) taskNoticeDeleted(notice *session.TaskNotice) bool { + return a.taskRecordDeleted(filepath.Base(filepath.Dir(a.file)), strconv.FormatUint(notice.ID, 10), strconv.FormatUint(notice.Parent, 10), notice.PlanID) +} + +func (a *app) keepPlanTaskRecords(rows []session.PlanTaskRow) []session.PlanTaskRow { + if len(a.deletedRecords) == 0 { + return rows + } + owner := filepath.Base(filepath.Dir(a.file)) + kept := make([]session.PlanTaskRow, 0, len(rows)) + for _, row := range rows { + if !a.taskRecordDeleted(owner, row.ID, row.Parent) { + kept = append(kept, row) + } + } + return kept +} + +// reconcileDeletedTasks updates the live projections together, before another +// frame is drawn. Renderers never invent their own definition of a deleted task. +func (a *app) reconcileDeletedTasks() { + if len(a.deletedRecords) == 0 { + return + } + a.comp.tasks = a.keepTaskRecords(a.comp.tasks) + if a.planRowsFront == a.frontGen { + kept := a.keepPlanTaskRecords(a.planRows) + if len(kept) != len(a.planRows) { + a.planRows = kept + a.planRowsGen++ + } + } + owner := filepath.Base(filepath.Dir(a.file)) + changed := false + for id, node := range a.tasks { + if !a.taskRecordDeleted(owner, strconv.FormatUint(id, 10), node.parent, node.planID) { + continue + } + if a.room != nil && a.room.guest == nil && a.room.id == id { + a.closeRoom() + } + if a.task != nil && a.task.id == id { + a.task = nil + } + if pilot := a.pilots[id]; pilot != nil && pilot.stop != nil { + pilot.stop() + } + delete(a.pilots, id) + delete(a.tasks, id) + delete(a.taskSeen, id) + delete(a.typedTaskBriefs, id) + changed = true + } + if !changed { + return + } + kept := a.taskOrder[:0] + for _, id := range a.taskOrder { + if a.tasks[id] != nil { + kept = append(kept, id) + } + } + a.taskOrder = kept + if !a.railWhere.onJobs() && a.tasks[a.railWhere.id] == nil { + a.railWhere = railSpot{} + a.railTop = 0 + if len(kept) > 0 { + a.railWhere.id = kept[0] + } else { + a.railHold = false + } + } + a.railStamp++ + a.touch() +} diff --git a/internal/tui3/taskdeletion_test.go b/internal/tui3/taskdeletion_test.go new file mode 100644 index 000000000..fb144f1ea --- /dev/null +++ b/internal/tui3/taskdeletion_test.go @@ -0,0 +1,135 @@ +package tui3 + +import ( + "fmt" + "path/filepath" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestTaskDeletionReconcilesLiveRailAndEveryRecordView(t *testing.T) { + for _, page := range []page{pageHome, pageTasks} { + t.Run(fmt.Sprint(page), func(t *testing.T) { + lab := newSwitchLab(t) + const ownerID = "bbbb000000000001" + rows := []session.TaskIndexEntry{ + {ID: "101", PlanID: "t-parent", Title: "Delete this live parent", SessionID: ownerID, Status: string(session.TaskDone), EndedAt: lab.now}, + {ID: "102", Parent: "101", Title: "Delete this live child", SessionID: ownerID, Status: string(session.TaskDone), EndedAt: lab.now}, + {ID: "103", Title: "Keep this live sibling", SessionID: ownerID, Status: string(session.TaskDone), EndedAt: lab.now}, + } + for _, row := range rows { + lab.task("-beta", row) + } + a := lab.open(180, 60) + owner := selectHomeTask(t, a, "101") + a.file, a.workspace = owner.Transcript, owner.Workspace + a.agent = &recordActionAgent{fakeAgent: &fakeAgent{model: "m"}, rows: rows} + notices := []session.TaskNotice{ + {ID: 101, PlanID: "t-parent", Title: rows[0].Title, State: session.TaskDone}, + {ID: 102, Parent: 101, Title: rows[1].Title, State: session.TaskDone}, + {ID: 103, Title: rows[2].Title, State: session.TaskDone}, + } + for i := range notices { + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: ¬ices[i]}) + } + if len(a.taskOrder) != 3 { + t.Fatal("fixture did not populate live rail") + } + a.comp.tasks = append([]session.TaskIndexEntry(nil), rows...) + a.planRowsFront = a.frontGen + a.planRows = []session.PlanTaskRow{{ID: "t-parent", Status: "done"}, {ID: "t-child", Parent: "t-parent", Status: "done"}, {ID: "t-sibling", Status: "done"}} + pilotStopped := false + a.pilots = map[uint64]*taskPilot{102: {id: 102, stop: func() { pilotStopped = true }}} + a.railWhere = railSpot{id: 102} + a.railHold = true + a.showPage(page) + // Both pages dispatch the same confirmed deletion command. + drain(t, a, a.deleteRecord(owner, &rows[0])) + assertKept := func() { + t.Helper() + if !reflect.DeepEqual(a.taskOrder, []uint64{103}) || a.tasks[101] != nil || a.tasks[102] != nil || a.tasks[103] == nil { + t.Fatalf("live rail retained deleted subtree: order=%v", a.taskOrder) + } + if a.railWhere.id != 103 { + t.Fatalf("rail cursor still points at removed task: %+v", a.railWhere) + } + for _, row := range a.comp.tasks { + if row.SessionID == ownerID && row.ID != "103" { + t.Fatalf("mention retained deleted row: %v", row.ID) + } + } + world := a.readWorld() + for _, project := range world.Projects { + for _, chat := range project.Sessions { + for _, row := range chat.Tasks.Rows { + if row.SessionID == ownerID && (row.ID == "101" || row.ID == "102") { + t.Fatal("Home world retained deleted task") + } + } + } + } + a.showPage(pageTasks) + for _, item := range a.tasksFiltered().items { + if item.entry.SessionID == ownerID && (item.entry.ID == "101" || item.entry.ID == "102") { + t.Fatal("Sessions retained deleted task") + } + } + } + assertKept() + if !pilotStopped || a.pilots[102] != nil { + t.Fatal("deleted task retained its watcher") + } + if len(a.planRows) != 1 || a.planRows[0].ID != "t-sibling" { + t.Fatal("cached plan retained deleted subtree") + } + if got := a.keepPlanTaskRecords([]session.PlanTaskRow{{ID: "t-parent"}, {ID: "t-child", Parent: "t-parent"}}); len(got) != 0 { + t.Fatal("late plan snapshot restored deleted tasks") + } + // Delayed events and snapshots must not resurrect either projection. + for i := range notices { + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: ¬ices[i]}) + } + drive(t, a, tasksLoadedMsg{rows: rows, known: true}) + assertKept() + a.dropTasks() + for i := range notices { + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: ¬ices[i]}) + } + if !reflect.DeepEqual(a.taskOrder, []uint64{103}) { + t.Fatalf("switch-back replay resurrected tasks: %v", a.taskOrder) + } + // Numeric graph IDs are local to their conversation. + a.dropTasks() + a.file = filepath.Join(filepath.Dir(filepath.Dir(owner.Transcript)), "other-owner", "transcript.jsonl") + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: ¬ices[0]}) + if a.tasks[101] == nil { + t.Fatal("deletion leaked into another conversation") + } + }) + } +} + +func TestPersistedTaskDeletionReconcilesAnAlreadyLoadedRail(t *testing.T) { + a, files := homeTabsFixture(t) + notice := session.TaskNotice{ID: 1, PlanID: "t-one", Title: "Deleted in another view", State: session.TaskDone} + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: ¬ice}) + dir := filepath.Dir(files[0]) + meta, err := session.LoadMeta(dir) + if err != nil { + t.Fatal(err) + } + meta.DeletedTasks = map[string]bool{"t-one": true} + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + a.readWorld() + if len(a.taskOrder) != 0 || a.tasks[1] != nil { + t.Fatal("persisted deletion did not reconcile loaded rail") + } + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: ¬ice}) + if len(a.taskOrder) != 0 { + t.Fatal("late aliased notice restored deleted task") + } +} diff --git a/internal/tui3/taskmention.go b/internal/tui3/taskmention.go index ad1478abf..565aea5a5 100644 --- a/internal/tui3/taskmention.go +++ b/internal/tui3/taskmention.go @@ -150,7 +150,7 @@ func (a *app) tasksLoaded(rows []session.TaskIndexEntry, known ...bool) tea.Cmd return tasksLoadedMsg{rows: rows, known: ready} }) } - a.comp.tasks, a.comp.tasksHeld = rows, false + a.comp.tasks, a.comp.tasksHeld = a.keepTaskRecords(rows), false if a.hosted() { a.adoptFarTaskRows(rows) } diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index 7bdb4a576..5f0ed064e 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -87,7 +87,7 @@ func (a *app) refreshPlanRows() tea.Cmd { if !here || front != a.frontGen { return nil } - a.planRows, a.planRowsRead, a.planRowsFront = rows, true, front + a.planRows, a.planRowsRead, a.planRowsFront = a.keepPlanTaskRecords(rows), true, front a.planRowsStamp, a.planRowsAt = stamp, a.now() a.planRowsGen++ a.touch() diff --git a/internal/tui3/taskrecordactions.go b/internal/tui3/taskrecordactions.go new file mode 100644 index 000000000..81648eb3f --- /dev/null +++ b/internal/tui3/taskrecordactions.go @@ -0,0 +1,161 @@ +package tui3 + +import ( + "fmt" + "path/filepath" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" +) + +// taskActionRows uses the same cached rows as the list. The menu freezes its +// verb; pressing Stop can never turn into Delete because a worker just landed. +func (a *app) taskActionRows(row session.SessionRow, entry session.TaskIndexEntry) []session.TaskIndexEntry { + rows := append([]session.TaskIndexEntry(nil), row.Tasks.Rows...) + rows = append(rows, entry) + for _, item := range a.taskSheet.reading.items { + if item.entry.SessionID == row.ID { + rows = append(rows, item.entry) + } + } + if a.convKey(row.Transcript) == a.frontTabKey() { + for _, plan := range a.planRows { + rows = append(rows, taskPlanRecord(plan, row.ID)) + } + } + return linkTaskPlanRows(rows) +} + +func taskPlanRecord(row session.PlanTaskRow, owner string) session.TaskIndexEntry { + return session.TaskIndexEntry{ID: row.ID, Parent: row.Parent, SessionID: owner, Title: row.Title, Status: row.Status, StartedAt: row.Started, EndedAt: row.Ended} +} + +func taskTreeActive(rows []session.TaskIndexEntry, owner, root string) bool { + for _, row := range session.TaskRecordTree(rows, owner, root) { + if session.TaskRecordActive(row) { + return true + } + } + return false +} + +func (a *app) taskRecordOwner(row session.SessionRow) Agent { + key := a.convKey(row.Transcript) + if key != "" && key == a.frontTabKey() { + return a.agent + } + if held := a.behind[key]; held != nil { + return held.conv.Agent + } + return nil +} + +// taskActionReading refreshes the selected owner's graph and plan off the UI +// loop. A task number from another conversation must never reach this one's agent. +func taskActionReading(owner Agent, id string, cached []session.TaskIndexEntry) ([]session.TaskIndexEntry, map[string]bool) { + rows := append([]session.TaskIndexEntry(nil), cached...) + if source, ok := owner.(interface { + TaskIndex() []session.TaskIndexEntry + }); ok { + rows = append(rows, source.TaskIndex()...) + } + plans := make(map[string]bool) + if source, ok := owner.(interface{ PlanTasks() []session.PlanTaskRow }); ok { + for _, row := range source.PlanTasks() { + rows = append(rows, taskPlanRecord(row, id)) + plans[row.ID] = true + } + } + return linkTaskPlanRows(rows), plans +} + +// keepTaskActionOwner retains a newly attached owner without moving the user's +// current conversation or interrupting the owner's unrelated work. +func (a *app) keepTaskActionOwner(conv Conversation) tea.Cmd { + if conv.Agent == nil { + return nil + } + key := a.convKey(conv.SessionFile) + if _, visible := chatTabAt(a.tabList(), key); !visible { + if a.tabShut == nil { + a.tabShut = make(map[string]bool) + } + a.tabShut[key] = true + } + return a.stow(conv, &aside{since: a.now()}) +} + +func (a *app) stopTaskRecord(row session.SessionRow, entry session.TaskIndexEntry) tea.Cmd { + owner, open := a.taskRecordOwner(row), a.open + cached := a.taskActionRows(row, entry) + if owner == nil && (open == nil || a.shared) { + a.taskRowNotice("open this conversation to stop its tasks") + return nil + } + a.taskRowNotice("stopping…") + return a.offLoop(func() func(bool) tea.Cmd { + var attached Conversation + var err error + if owner == nil { + attached, err = open(row.Workspace, row.Transcript) + if err == nil && filepath.Clean(attached.SessionFile) != filepath.Clean(row.Transcript) { + err = fmt.Errorf("conversation identity changed; task was not stopped") + } + if err == nil { + owner = attached.Agent + } + } + if err == nil { + rows, plans := taskActionReading(owner, row.ID, cached) + for _, task := range session.TaskRecordTree(rows, row.ID, entry.ID) { + if !session.TaskRecordActive(task) || plans[task.PlanID] { + continue + } + if plans[task.ID] { + if door, ok := owner.(interface{ PlanCancel(string) error }); ok { + err = door.PlanCancel(task.ID) + } else { + err = fmt.Errorf("task stop is unavailable") + } + } else if door, ok := owner.(stopAgent); ok { + _, err = door.Cancel(session.CancelTask + ":" + task.ID) + } else { + err = fmt.Errorf("task stop is unavailable") + } + if err != nil { + break + } + } + } + return func(bool) tea.Cmd { + cmd := a.keepTaskActionOwner(attached) + a.railStamp++ + a.refreshRecordLists() + if err != nil { + a.taskRowNotice("could not stop: " + err.Error()) + } else { + a.taskRowNotice("stop requested · task records kept") + } + return cmd + } + }) +} + +// A run started from a numbered task uses that exact number in its plan ID. +// Linking the two records keeps deletion independent of titles and list order. +func linkTaskPlanRows(rows []session.TaskIndexEntry) []session.TaskIndexEntry { + plans := make(map[tasksKey]bool) + for _, row := range rows { + if strings.HasPrefix(row.ID, "t-") { + plans[tasksKeyOf(row)] = true + } + } + for i := range rows { + row := &rows[i] + if row.PlanID == "" && plans[tasksKey{session: row.SessionID, id: "t-" + row.ID}] { + row.PlanID = "t-" + row.ID + } + } + return rows +} diff --git a/internal/tui3/taskrowverbs.go b/internal/tui3/taskrowverbs.go index 6a11dbfa6..d7f9f8c63 100644 --- a/internal/tui3/taskrowverbs.go +++ b/internal/tui3/taskrowverbs.go @@ -10,7 +10,7 @@ import ( ) // taskRowVerbs supplies the same task options to home and the Tasks page. -// Archive addresses the task inside its owner, never the conversation itself. +// Tasks stop while active and delete once their whole subtree has settled. func (a *app) taskRowVerbs(row session.SessionRow, entry session.TaskIndexEntry) []verb { if a.hosted() { return nil @@ -21,13 +21,16 @@ func (a *app) taskRowVerbs(row session.SessionRow, entry session.TaskIndexEntry) dir = filepath.Dir(row.Transcript) } if dir != "" && row.ID != "" && row.ID == entry.SessionID && entry.ID != "" { - word := "close" - archived := row.ArchivedTasks[entry.ID] - if archived { - word = "reopen" + word := "delete" + active := taskTreeActive(a.taskActionRows(row, entry), row.ID, entry.ID) + if active { + word = "stop" } verbs = append(verbs, verb{key: 'x', word: word, do: func() tea.Cmd { - return a.putTaskAway(dir, entry, !archived) + if active { + return a.stopTaskRecord(row, entry) + } + return a.askRecordDelete(row, &entry) }}) } if workspace := strings.TrimSpace(row.Workspace); workspace != "" { @@ -66,36 +69,3 @@ func (a *app) taskRowNotice(words string) { } a.touch() } - -// putTaskAway persists only the visibility preference. Both lists then read -// fresh metadata, so engine updates cannot resurrect a task somebody put away. -func (a *app) putTaskAway(dir string, entry session.TaskIndexEntry, archived bool) tea.Cmd { - if err := session.SetTaskArchived(dir, entry.SessionID, entry.ID, archived); err != nil { - a.taskRowNotice("could not change task visibility: " + err.Error()) - return nil - } - if a.at(pageHome) { - a.refreshHome() - } else { - p := &a.taskSheet - if archived { - if p.closed == nil { - p.closed = make(map[tasksKey]bool) - } - p.closed[tasksKeyOf(entry)] = true - p.closedQuery = p.query.String() - } else { - delete(p.closed, tasksKeyOf(entry)) - } - p.world = a.readWorld() - p.mine = a.taskSheetMine() - p.reading = readTasks(p.world, p.mine, p.reading.win, p.order, p.reading.seen, a.now()) - p.cursor = a.tasksSettle(p.cursor) - } - word := "task reopened" - if archived { - word = "task closed · find it by typing its name in tasks" - } - a.taskRowNotice(word) - return a.taskPaneFollow() -} diff --git a/internal/tui3/taskrowverbs_test.go b/internal/tui3/taskrowverbs_test.go index 96c25029e..315e0eb3d 100644 --- a/internal/tui3/taskrowverbs_test.go +++ b/internal/tui3/taskrowverbs_test.go @@ -2,6 +2,7 @@ package tui3 import ( "errors" + "fmt" "reflect" "strings" "testing" @@ -24,94 +25,98 @@ func selectHomeTask(t *testing.T, a *app, id string) session.SessionRow { return session.SessionRow{} } -func TestTaskClosePersistsAndCanBeRestoredFromTheTasksFilter(t *testing.T) { - lab := newSwitchLab(t) - a := lab.open(180, 40) - owner := selectHomeTask(t, a, "t1") - drive(t, a, key("right")) - frame := taskSheetText(a) - for _, word := range []string{"x close", "n new in project", "o open folder", "p copy project"} { - if !strings.Contains(frame, word) { - t.Fatalf("task options are missing %q:\n%s", word, frame) - } - } - drive(t, a, key("a")) - before, err := session.LoadMeta(owner.Dir) - if err != nil || before.ArchivedTasks["t1"] { - t.Fatalf("the retired a shortcut changed task visibility: %+v, %v", before, err) - } - drive(t, a, key("x")) - meta, err := session.LoadMeta(owner.Dir) - if err != nil || !meta.ArchivedTasks["t1"] || meta.Archived { - t.Fatalf("task archive was not independent of its conversation: %+v, %v", meta, err) - } - for _, item := range a.tasksFiltered().items { - if item.entry.ID == "t1" && item.entry.SessionID == owner.ID { - t.Fatal("the open Sessions list still contains the task immediately after close") - } - } - b := lab.open(180, 40) - for _, line := range b.home.lines { - if line.cell != nil && line.cell.row != nil && line.cell.row.task != nil && line.cell.row.task.ID == "t1" { - t.Fatal("a fresh window restored a task that was put away") - } +type recordActionAgent struct { + *fakeAgent + rows []session.TaskIndexEntry + calls []string + fail error +} + +func (a *recordActionAgent) TaskIndex() []session.TaskIndexEntry { return a.rows } +func (a *recordActionAgent) Cancel(id string) (string, error) { + a.calls = append(a.calls, id) + if a.fail != nil { + return "", a.fail } - b.showPage(pageTasks) - for _, item := range b.tasksFiltered().items { - if item.entry.ID == "t1" && item.entry.SessionID == owner.ID { - t.Fatal("the resting Tasks list still contains the put-away task") + for i := range a.rows { + if "task:"+a.rows[i].ID == id { + a.rows[i].Status = string(session.TaskFailed) } } - b.taskSheet.query.setText("read 40 filings") - b.taskSheetTyped() - found := false - for _, at := range b.taskSheet.stops(b) { - b.taskSheet.cursor = at - if item, ok := b.taskSheetCurrent(); ok && item.entry.ID == "t1" && item.entry.SessionID == owner.ID { - if !item.runs { - t.Fatal("putting away the task stopped its running work") + return "stopped", nil +} + +func TestTaskStopTargetsItsOwnerAndNeverDeletesAfterCompletion(t *testing.T) { + for _, settled := range []bool{false, true} { + t.Run(fmt.Sprint(settled), func(t *testing.T) { + lab := newSwitchLab(t) + a := lab.open(180, 40) + owner := selectHomeTask(t, a, "t1") + item, _ := a.taskSheetCurrent() + agent := &recordActionAgent{fakeAgent: &fakeAgent{model: "m"}, rows: []session.TaskIndexEntry{item.entry}} + var asked string + a.open = func(workspace, file string) (Conversation, error) { + asked = file + return Conversation{Agent: agent, SessionFile: file, Workspace: workspace}, nil } - found = true - break - } - } - if !found { - t.Fatal("the filter could not recover the archived task record") - } - drive(t, b, key("right")) - if !strings.Contains(taskSheetText(b), "x reopen") { - t.Fatal("the filtered task did not offer restore") - } - drive(t, b, key("x")) - meta, _ = session.LoadMeta(owner.Dir) - if meta.ArchivedTasks["t1"] { - t.Fatal("restore did not persist") + before := a.file + drive(t, a, key("right")) + for _, word := range []string{"x stop", "n new in project", "o open folder", "p copy project"} { + if !strings.Contains(taskSheetText(a), word) { + t.Fatalf("missing %q", word) + } + } + if settled { + agent.rows[0].Status = string(session.TaskDone) + } + drive(t, a, key("x")) + if asked != owner.Transcript || a.file != before { + t.Fatal("stop used or changed the wrong conversation") + } + if len(agent.calls) != map[bool]int{false: 1, true: 0}[settled] { + t.Fatalf("stop calls=%v", agent.calls) + } + meta, _ := session.LoadMeta(owner.Dir) + if meta.ArchivedTasks["t1"] || meta.DeletedTasks["t1"] || a.strip.prompt != "" { + t.Fatal("stop archived or deleted a record") + } + }) } - selectHomeTask(t, lab.open(180, 40), "t1") } -func TestTaskCloseFailureKeepsTheRowVisible(t *testing.T) { - a := newSwitchLab(t).open(180, 40) +func TestFinishedTasksOfferConfirmedDeleteAndCancelRestoresActions(t *testing.T) { + lab := newSwitchLab(t) + lab.task("-beta", session.TaskIndexEntry{ID: "t1", SessionID: "bbbb000000000001", Title: "read 40 filings", Status: string(session.TaskDone), EndedAt: lab.now}) + a := lab.open(180, 40) owner := selectHomeTask(t, a, "t1") - a.putTaskAway(owner.Dir, session.TaskIndexEntry{ID: "t1", SessionID: "wrong-owner"}, true) - selectHomeTask(t, a, "t1") + drive(t, a, key("right"), key("x")) + if !strings.Contains(a.strip.prompt, deletePermanentWord) { + t.Fatal("delete did not ask") + } + drive(t, a, key("enter")) meta, _ := session.LoadMeta(owner.Dir) - if meta.ArchivedTasks["t1"] { - t.Fatal("a mismatched task owner changed the row's visibility") + if meta.DeletedTasks["t1"] { + t.Fatal("Enter confirmed deletion") + } + drive(t, a, key("n")) + if !a.strip.open || len(a.strip.verbs) != 4 || a.strip.verbs[0].word != "delete" { + t.Fatal("cancel did not restore four actions") } } -func TestFinishedTasksOfferCloseAndFolderActions(t *testing.T) { +func TestTaskStopFailureKeepsTheRecordAndConversation(t *testing.T) { lab := newSwitchLab(t) - lab.presence("-beta", "bbbb000000000001", session.PresenceIdle, "", lab.now) - lab.task("-beta", session.TaskIndexEntry{ID: "t1", SessionID: "bbbb000000000001", - Title: "read 40 filings", Label: "read 40 filings", Status: string(session.TaskDone), EndedAt: lab.now}) a := lab.open(180, 40) owner := selectHomeTask(t, a, "t1") + item, _ := a.taskSheetCurrent() + agent := &recordActionAgent{fakeAgent: &fakeAgent{model: "m"}, rows: []session.TaskIndexEntry{item.entry}, fail: errors.New("stop refused")} + a.open = func(workspace, file string) (Conversation, error) { + return Conversation{Agent: agent, SessionFile: file, Workspace: workspace}, nil + } drive(t, a, key("right"), key("x")) meta, _ := session.LoadMeta(owner.Dir) - if !meta.ArchivedTasks["t1"] { - t.Fatal("a finished task could not be put away") + if meta.DeletedTasks["t1"] || !strings.Contains(a.taskSheet.actionNote, "stop refused") { + t.Fatal("stop failure was hidden or deleted its record") } } @@ -195,65 +200,103 @@ func TestTaskOptionsSuspendTheLandingAnswerHints(t *testing.T) { } } -func TestClosingAFilteredTaskRemovesItUntilTheFilterChanges(t *testing.T) { +func TestTaskDeleteRechecksNewlyActiveDescendantsOnYes(t *testing.T) { lab := newSwitchLab(t) + lab.task("-beta", session.TaskIndexEntry{ID: "t1", SessionID: "bbbb000000000001", Title: "read 40 filings", Status: string(session.TaskDone), EndedAt: lab.now}) a := lab.open(180, 40) owner := selectHomeTask(t, a, "t1") - a.taskSheet.query.setText("read 40 filings") - a.taskSheetTyped() - for _, at := range a.taskSheet.stops(a) { - a.taskSheet.cursor = at - if item, ok := a.taskSheetCurrent(); ok && item.entry.ID == "t1" && item.entry.SessionID == owner.ID { - break - } - } + item, _ := a.taskSheetCurrent() + agent := &recordActionAgent{fakeAgent: &fakeAgent{model: "m"}, rows: []session.TaskIndexEntry{item.entry}} + a.file, a.agent = owner.Transcript, agent drive(t, a, key("right"), key("x")) - if a.taskSheet.query.String() != "read 40 filings" { - t.Fatal("closing a task discarded the filter") + if a.strip.prompt == "" { + t.Fatal("delete did not confirm") } - for _, item := range a.tasksFiltered().items { - if item.entry.ID == "t1" && item.entry.SessionID == owner.ID { - t.Fatal("the filtered Sessions list still contains the task immediately after close") - } + agent.rows = append(agent.rows, session.TaskIndexEntry{ID: "child", Parent: "t1", SessionID: owner.ID, Title: "child", Status: string(session.TaskRunning)}) + drive(t, a, key("y")) + meta, _ := session.LoadMeta(owner.Dir) + if meta.DeletedTasks["t1"] || len(agent.calls) > 0 || !strings.Contains(a.taskSheet.actionNote, "still active") { + t.Fatalf("confirmation deleted or stopped new work: %s", a.taskSheet.actionNote) } - // A live update must not restore a row closed in the current search. - a.railStamp++ - for _, item := range a.tasksFiltered().items { - if item.entry.ID == "t1" && item.entry.SessionID == owner.ID { - t.Fatal("a refresh restored the closed task") - } +} + +func TestTaskParentStopsActiveDescendantsAndOffersDeleteAfterTheySettle(t *testing.T) { + lab := newSwitchLab(t) + a := lab.open(180, 40) + owner := selectHomeTask(t, a, "t1") + rows := []session.TaskIndexEntry{ + {ID: "t1", SessionID: owner.ID, Title: "Parent", Status: string(session.TaskDone)}, + {ID: "child", Parent: "t1", SessionID: owner.ID, Title: "Child", Status: string(session.TaskQueued)}, + {ID: "grandchild", Parent: "child", SessionID: owner.ID, Title: "Grandchild", Status: string(session.TaskRunning)}, + {ID: "sibling", SessionID: owner.ID, Title: "Sibling", Status: string(session.TaskRunning)}, } - // An edit that leaves the search text as it was is not a new search: ctrl+k - // with the caret already at the end of the box takes nothing. - drive(t, a, key("ctrl+k")) - if a.taskSheet.query.String() != "read 40 filings" { - t.Fatalf("ctrl+k at the end changed the filter to %q", a.taskSheet.query.String()) + agent := &recordActionAgent{fakeAgent: &fakeAgent{model: "m"}, rows: rows} + a.file, a.agent = owner.Transcript, agent + a.taskSheet.reading.items = nil + owner.Tasks.Rows = rows + verbs := a.taskRowVerbs(owner, rows[0]) + if verbs[0].word != "stop" { + t.Fatal("settled parent offered delete over active children") } - for _, item := range a.tasksFiltered().items { - if item.entry.ID == "t1" && item.entry.SessionID == owner.ID { - t.Fatal("an edit that left the search text unchanged restored the closed task") - } + drain(t, a, verbs[0].do()) + if len(agent.calls) != 2 { + t.Fatalf("calls=%v", agent.calls) } - a.taskSheet.query.setText("filings") - a.taskSheetTyped() - found := false - for _, at := range a.taskSheet.stops(a) { - a.taskSheet.cursor = at - if item, ok := a.taskSheetCurrent(); ok && item.entry.ID == "t1" && item.entry.SessionID == owner.ID { - found = true - break + for _, id := range agent.calls { + if id == "task:t1" || id == "task:sibling" { + t.Fatalf("stopped unrelated task: %s", id) } } - if !found { - t.Fatal("a new search did not recover the closed task") + a.taskSheet.reading.items = nil + owner.Tasks.Rows = agent.rows + if got := a.taskRowVerbs(owner, agent.rows[0])[0].word; got != "delete" { + t.Fatalf("settled subtree offers %s", got) } - drive(t, a, key("right")) - if !strings.Contains(taskSheetText(a), "x reopen") { - t.Fatal("the recovered task did not offer reopen") +} + +func TestFormerlyClosedTasksRemainVisible(t *testing.T) { + lab := newSwitchLab(t) + a := lab.open(180, 40) + owner := selectHomeTask(t, a, "t1") + if err := session.SetTaskArchived(owner.Dir, owner.ID, "t1", true); err != nil { + t.Fatal(err) + } + fresh := lab.open(180, 40) + selectHomeTask(t, fresh, "t1") +} + +func TestHomeTaskOptionsUseStopInsteadOfClose(t *testing.T) { + lab := newSwitchLab(t) + a := lab.open(180, 40) + owner := selectHomeTask(t, a, "t1") + item, _ := a.taskSheetCurrent() + line := homeLine{cell: &homeCell{row: &switcherRow{session: owner, task: &item.entry}}} + verbs := a.runningVerbs(line) + if len(verbs) != 4 || verbs[0].key != 'x' || verbs[0].word != "stop" { + t.Fatalf("home task actions=%v", verbs) + } +} + +func TestTaskStopUsesPlanDoorForWaitingSubtree(t *testing.T) { + lab := newSwitchLab(t) + a := lab.open(180, 40) + owner := selectHomeTask(t, a, "t1") + agent := &planFake{taskFake: &taskFake{fakeAgent: &fakeAgent{model: "m"}}, plan: []session.PlanTaskRow{ + {ID: "t-1", Title: "Root", Status: "done"}, + {ID: "t-child", Parent: "t-1", Title: "Child", Status: "paused"}, + {ID: "t-sibling", Title: "Sibling", Status: "running"}, + }} + root := session.TaskIndexEntry{ID: "1", Title: "Root", SessionID: owner.ID, Status: string(session.TaskDone)} + a.file, a.agent = owner.Transcript, agent + a.planRows = agent.plan + a.taskSheet.reading.items = nil + owner.Tasks.Rows = []session.TaskIndexEntry{root} + verbs := a.taskRowVerbs(owner, root) + if verbs[0].word != "stop" { + t.Fatal("waiting plan child offered Delete") } - drive(t, a, key("x")) - meta, err := session.LoadMeta(owner.Dir) - if err != nil || meta.ArchivedTasks["t1"] { - t.Fatalf("reopening the recovered task failed: %+v, %v", meta, err) + drain(t, a, verbs[0].do()) + if !reflect.DeepEqual(agent.cancelled, []string{"t-child"}) { + t.Fatalf("wrong plan work stopped: %v", agent.cancelled) } } diff --git a/internal/tui3/tasksconversation_test.go b/internal/tui3/tasksconversation_test.go index 131f4be5b..e02da6264 100644 --- a/internal/tui3/tasksconversation_test.go +++ b/internal/tui3/tasksconversation_test.go @@ -214,8 +214,14 @@ func TestAConversationRowIsNeitherRunnableNorCancellable(t *testing.T) { if _, ok := a.taskSheetCurrent(); ok { t.Fatal("a conversation answered the question every caller asks about a piece of work") } - if verbs := a.taskSheet.verbs(a); len(verbs) != 0 { - t.Fatalf("the conversation was offered %d verbs of its own", len(verbs)) + verbs := a.taskSheet.verbs(a) + if len(verbs) != 4 { + t.Fatalf("the conversation was offered %d verbs, want the four Home actions", len(verbs)) + } + for _, v := range verbs { + if v.word == stopActWord { + t.Fatal("a conversation was offered a task cancellation verb") + } } if chat.row.Transcript != "/journals/room-a/session.jsonl" { t.Fatalf("the conversation's door points at %q", chat.row.Transcript) diff --git a/internal/tui3/tasksconversations.go b/internal/tui3/tasksconversations.go index 69623fe27..35a838702 100644 --- a/internal/tui3/tasksconversations.go +++ b/internal/tui3/tasksconversations.go @@ -26,6 +26,15 @@ func tasksConversationRows(world session.World, mine tasksMine, win session.Usag return } if i, found := at[row.ID]; found { + // Live status cannot replace the saved identity or closure metadata. + // Delete needs this exact owner folder, including for the foreground + // conversation whose live row was built without a world scan. + if rows[i].Dir != "" { + row.Dir = rows[i].Dir + } + row.Owned = rows[i].Owned + row.Archived = rows[i].Archived + row.ArchivedTasks, row.DeletedTasks = rows[i].ArchivedTasks, rows[i].DeletedTasks if row.Project == "" { row.Project = rows[i].Project } diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 65dc36310..2f8e960bd 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -177,6 +177,7 @@ type tasksMineRow struct { type tasksChatView struct { title string working, unread bool + closed bool } // tasksReading is everything drawing and routing need from one world reading. @@ -279,6 +280,9 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by var order []tasksKey held := map[tasksKey]tasksItem{} put := func(key tasksKey, item tasksItem) { + if item.row.DeletedTasks[item.entry.ID] { + return + } if _, seen := held[key]; !seen { order = append(order, key) } @@ -759,6 +763,7 @@ func (r tasksReading) lay(width int) []tasksLine { if view, ok := r.chatViews[line.chat.row.Transcript]; ok { line.chat.title = view.title line.chat.working, line.chat.unread = view.working, view.unread + line.chat.row.Archived = view.closed } line.kin = "" lines = append(lines, line) @@ -1930,7 +1935,7 @@ func tasksChatRow(line tasksLine, width int, now time.Time, folder, tilde string lead := tasksBareLead + pal.dim(tasksTreeLead(line, width, pal)) + bullet + " " return tasksTableRow(lead, ansi.StringWidth(lead), name, "", tasksChatStateField(chat), tasksKeyField(by.key, line.rank, now), - tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit, project, tasksFoldMark(line, pal)) + tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit, chat.row.Archived, project, tasksFoldMark(line, pal)) } // tasksRow is one piece of work on a wide frame, and it is A ROW OF A TABLE: the @@ -1969,7 +1974,7 @@ func tasksRow(line tasksLine, width int, now time.Time, by tasksSort, pal palett } return tasksTableRow(lead, cells, label, item.entry.Program, state, second, - tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit, "", tasksFoldMark(line, pal)) + tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit, false, "", tasksFoldMark(line, pal)) } // tasksAgeField reads recorded activity rather than dating every live task now. diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index 802f75277..c10808158 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -184,7 +184,7 @@ func tasksChatStateField(chat tasksChat) rowField { // handed to, "" for every other row, and the badge is paid for out of the name // the way the lead is, so no column moves for it. func tasksTableRow(lead string, leadCells int, name, program string, state, second rowField, - secondInk func(string) string, width int, key tasksSortKey, pal palette, lit bool, project, fold string) string { + secondInk func(string) string, width int, key tasksSortKey, pal palette, lit, closed bool, project, fold string) string { stateCells, secondCells, nameCells := tasksColumns(width, key) nameCells = max(nameCells-leadCells, 1) foldCells := 0 @@ -194,7 +194,11 @@ func tasksTableRow(lead string, leadCells int, name, program string, state, seco room := max(nameCells-foldCells-tasksColumnAir, 1) wears := programSpelling(programBadge(program), name, room, railTitleFloor) said := fit(name, max(room-programCells(wears), 1)) - out := lead + placeSubject(said, lit, pal) + pal.programAfter(wears) + subject := placeSubject(said, lit, pal) + if closed { + subject = pal.dim(said) + } + out := lead + subject + pal.programAfter(wears) if fold != "" { out += " " + pal.dim(fold) } diff --git a/internal/tui3/taskview.go b/internal/tui3/taskview.go index 8c3316abc..e2449eb37 100644 --- a/internal/tui3/taskview.go +++ b/internal/tui3/taskview.go @@ -485,6 +485,10 @@ func (a *app) taskSheetAwayRows() []session.ElsewhereTask { tasks := a.elsewhere().Tasks() out := make([]session.ElsewhereTask, 0, len(tasks)) for _, task := range tasks { + // A stale presence cache must not rebuild a conversation just deleted. + if a.deletedRecords[tasksKey{session: task.SessionID}] || a.deletedRecords[tasksKey{session: task.SessionID, id: task.Task.ID}] { + continue + } if strings.TrimSpace(task.Task.Title) == "" { continue } diff --git a/internal/tui3/verbstrip.go b/internal/tui3/verbstrip.go index f9a57b317..3b02a3f6d 100644 --- a/internal/tui3/verbstrip.go +++ b/internal/tui3/verbstrip.go @@ -62,9 +62,11 @@ type verb struct { // rebuild, because it is about the cursor's destination rather than about how // the cursor got there ([app.holdStrip]). type verbStrip struct { - open bool - row string - verbs []verb + // prompt replaces the row actions with an explicit confirmation. + prompt string + open bool + row string + verbs []verb } // stripHint is the line under the strip while it is up (SCREEN 3c). It names @@ -128,6 +130,22 @@ func (a *app) stripKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { if !a.strip.open { return nil, false } + if a.strip.prompt != "" { + switch msg.String() { + case "y", "n", "esc": + choice := msg.String() + if choice == "esc" { + choice = "n" + } + for _, v := range a.strip.verbs { + if string(v.key) == choice { + a.closeStrip() + return v.do(), true + } + } + } + return nil, true + } switch msg.String() { case "esc", "left": a.closeStrip() @@ -149,8 +167,8 @@ func (a *app) stripKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // before the next key — finds the row is not the captured one and drops it. for _, v := range a.strip.verbs { if msg.String() == string(v.key) { - cmd := v.do() a.closeStrip() + cmd := v.do() a.touch() return cmd, true } @@ -247,7 +265,7 @@ func (a *app) verbStripRow(width int) []string { if width < 2 || len(a.strip.verbs) == 0 { return nil } - return verbChoiceLines(a.strip.verbs, width, " ", a.pal) + return a.stripChoiceLines(width, " ", a.pal) } // verbChoiceLines shares the same option layout between a list and home's @@ -315,3 +333,16 @@ const ( memoryForgetWord = "forget it" memoryUndoWord = "put it back" ) + +// stripChoiceLines keeps the confirmation visible in both strip placements. +func (a *app) stripChoiceLines(width int, lead string, pal palette) []string { + var rows []string + if a.strip.prompt != "" { + for _, line := range strings.Split(a.strip.prompt, "\n") { + for _, part := range wrap(line, max(1, width-ansi.StringWidth(lead))) { + rows = append(rows, lead+pal.ink(part)) + } + } + } + return append(rows, verbChoiceLines(a.strip.verbs, width, lead, pal)...) +} diff --git a/internal/tui3/verbstrip_test.go b/internal/tui3/verbstrip_test.go index 7c44d9594..bd5d41feb 100644 --- a/internal/tui3/verbstrip_test.go +++ b/internal/tui3/verbstrip_test.go @@ -68,14 +68,14 @@ func TestTheStripCannotOutliveTheRowItWasOpenedOn(t *testing.T) { if a.strip.open { t.Fatalf("%s left the strip standing under a row it was not about:\n%s", walk, screen) } - if strings.Contains(screen, "s "+stopActWord) { + if strings.Contains(screen, "p copy project") { t.Fatalf("%s left the strip's letters drawn:\n%s", walk, screen) } // AND THE LETTER IS A LETTER AGAIN. This is the half that cost a task: // the strip drawn under the new row made `s` look like that row's verb. - drive(t, a, key("s")) - if len(agent.asked) != 0 { - t.Fatalf("after %s, `s` asked the engine %v", walk, agent.asked) + drive(t, a, key("p")) + if len(agent.asked) != 0 || strings.HasPrefix(a.taskSheet.actionNote, "copied ") { + t.Fatalf("after %s, `p` acted on the old row; engine requests %v", walk, agent.asked) } }) } @@ -98,9 +98,9 @@ func TestTheStripCannotOutliveTheRowAWheelWalksOffIt(t *testing.T) { if a.strip.open { t.Fatal("a wheel tick left the strip standing under a row it was not about") } - drive(t, a, key("s")) - if len(agent.asked) != 0 { - t.Fatalf("after a wheel tick, `s` asked the engine %v", agent.asked) + drive(t, a, key("p")) + if len(agent.asked) != 0 || strings.HasPrefix(a.taskSheet.actionNote, "copied ") { + t.Fatalf("after a wheel tick, `p` acted on the old row; engine requests %v", agent.asked) } } @@ -119,8 +119,8 @@ func TestAKeyThatMovesNothingLeavesTheStripStanding(t *testing.T) { if !a.strip.open { t.Fatal("`end` on the last row closed a strip whose row had not moved") } - drive(t, a, key("s")) - if len(agent.asked) != 1 { - t.Fatalf("`s` on the row the strip is still about asked the engine %v", agent.asked) + drive(t, a, key("p")) + if !strings.HasPrefix(a.taskSheet.actionNote, "copied ") || len(agent.asked) != 0 { + t.Fatalf("`p` did not copy the unchanged row: notice=%q, engine requests=%v", a.taskSheet.actionNote, agent.asked) } }