From 6aa29741f625637a3ff817f3eff1874b70da6372 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:00:02 -0400 Subject: [PATCH 1/4] Filter Sessions by whole conversations and their task names --- internal/manual/chat/places.md | 5 ++- internal/manual/chat/tasks.md | 27 ++++++------ internal/tui3/place_sessions.go | 57 +++++++++++-------------- internal/tui3/sessionsfilter_test.go | 56 ++++++++++++++++++++++++ internal/tui3/tasksconversation_test.go | 8 ++-- 5 files changed, 104 insertions(+), 49 deletions(-) create mode 100644 internal/tui3/sessionsfilter_test.go diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index bc61feaaf2..8bc8fb2ae6 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -183,8 +183,9 @@ went to the filter at the top of the list, and on spend the words went nowhere a is gone (2026-09-17). **Typing still filters where a list is worth filtering.** On **sessions** every printable key -narrows the list, and the letters draw on the control row at the top of it beside the `⌕` -mark. On **search** the words you type are the query, drawn on the first row of the body the +narrows the conversation list by conversation name, project name or path, or nested task +name. Matching conversations keep their complete task trees. The letters draw on the +control row at the top beside the `⌕` mark. On **search** the words you type are the query, drawn on the first row of the body the same way, and `esc` clears them. On **memory** the head row echoes the filter in place of `type to filter`. Spend and standing take no text. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index bfbd8c8a7a..5d257a77bc 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -2896,12 +2896,13 @@ wisp · /Users/ada/code/wisp - Inside a task this is absent, like the rest of it: a task sees the pieces it handed out itself and nothing wider. -## Searching the task page: type to filter, find an old task by name, where the words I type appear, my cursor jumped to another task while I was reading +## Filter Sessions by conversation name, project or task name — searching the task page, where the words I type appear, my cursor jumped to another task while I was reading -**Just type.** On the task page every printable key — letters, the space, and digits -everywhere they are not an answer — builds a filter, and every section narrows against it as -you go. The two exceptions are `1` and `2` over a row the record pane beside the list is -drawing answers for, which answer it: see *Answer a task from the list*. +**Just type.** On Sessions every printable key — letters, the space, and digits +everywhere they are not an answer — builds a filter. It selects whole conversations +by conversation name, project name or path, or any nested task name. A match keeps +the conversation’s complete task tree, including its other tasks, visible. The two exceptions +are `1` and `2` over a row the record pane beside the list is drawing answers for, which answer it: see *Answer a task from the list*. ``` ⌕ parser state age ↓ @@ -2912,16 +2913,16 @@ reading ink with the dim `type to filter` standing in the box until you type. It an echo on a note line UNDER the rows your keystrokes had just changed; it is at the top of the list now, where the typing goes. -- It matches a task's **title**, its **id** (typed exactly: `7` finds task 7 and nothing - else), its **name** as the `@` list spells it, and its **outcome**. Letters in order are +- Task matching includes its visible **label**, full **title**, exact **id**, + **name** as the `@` list spells it, and **outcome**. The result is the task’s whole + conversation. Letters in order are enough — `prsr` finds `Port the parser`. - **Every section is filtered at once**, another window's rows included — those match on - their **title only**, never on an id, because ids restart with every conversation and `7` + their **label or title**, never on an id, because ids restart with every conversation and `7` typed here is a number you read in *this* window. A section with no match is not drawn at all, heading and all, so a filter that only matches old work leaves the `earlier` list alone on the page. -- It also matches the **conversation or project** a row came out of, because that is drawn - on the row and anything on screen is something you can search for. +- A **conversation or project** match also finds conversations with no tasks. - `backspace` deletes a character, `ctrl+w` a word, `ctrl+u` all of it. - **`esc` clears the filter first and closes the page on the second press** — the same layering the settings panel's search has. `ctrl+.` closes the page from anywhere. @@ -5275,8 +5276,10 @@ filter`. That is where your letters land — there is no message to send from th every printable key goes to the filter. `backspace` takes one back, `ctrl+u` clears the box, `ctrl+w` takes a word. -The query is matched against the task's name, the main chat's title, the state word and the -file paths the work touched. Every section narrows at once, and a section the query empties +The query selects whole conversations by conversation name, project name or path, or any +nested task name. A match keeps all tasks in that conversation, with their nesting intact. +Conversations with no tasks can match their own name or project. Every section narrows at +once, and a section the query empties is not drawn at all. A query that matches nothing keeps the page's own heading and count and says `nothing matches` under the list — there **is** work here, and your words are hiding it. diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index 5e5a558631..813b86403e 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -410,51 +410,43 @@ func (p *tasksPlace) filtered(a *app) tasksReading { // fold somebody left shut is not a decision they made about a list they had // not yet asked for. r.unfolded = true - // AND A MATCH IS SHOWN WHERE IT SITS. The work ABOVE a hit — the piece of work - // it was cut out of, and the conversation that asked for that — is kept even - // though it matches nothing, because the row above a hit is the one thing on - // the page that explains it. It used to be dropped, which promoted the hit to - // a root and left a person reading a worker with no idea whose it was. - hit := make(map[tasksKey]bool, len(r.items)) - found := make([]tasksKey, 0, len(r.items)) - for _, item := range r.items { - if tasksMatches(item, needle) || session.TaskWordsMatch(r.chatViews[item.row.Transcript].title, needle) { - key := tasksKeyOf(item.entry) - hit[key] = true - found = append(found, key) + // The filter selects conversations, not fragments of their work. A match + // on any descendant keeps its whole conversation, including siblings and + // descendants that do not repeat the query. Ownership, never a shared task + // number or title, decides which rows travel together. + owners := make(map[string]bool) + chatMatches := func(row session.SessionRow) bool { + title := row.Title + if view := r.chatViews[row.Transcript]; view.title != "" { + title = view.title } + return session.TaskWordsMatch(title+" "+row.Project+" "+row.Workspace, needle) } - tree := r.tree() - for _, key := range found { - // The hit set ends the walk when a path has already been visited. - for at := key; ; { - up, ok := tree.up[at] - if !ok || hit[up] { - break - } - hit[up] = true - at = up + for _, row := range r.chats { + if chatMatches(row) { + owners[row.ID] = true } } - kept := make([]tasksItem, 0, len(hit)) for _, item := range r.items { - if hit[tasksKeyOf(item.entry)] { + if tasksMatches(item, needle) || chatMatches(item.row) { + owners[tasksChatOf(item)] = true + } + } + kept := make([]tasksItem, 0, len(r.items)) + for _, item := range r.items { + if owners[tasksChatOf(item)] { kept = append(kept, item) } } r.items = kept chats := make([]session.SessionRow, 0, len(r.chats)) - owners := make(map[string]bool) - for _, item := range kept { - owners[tasksChatOf(item)] = true - } for _, row := range r.chats { - if owners[row.ID] || session.TaskWordsMatch(row.Title+" "+row.Project+" "+r.chatViews[row.Transcript].title, needle) { + if owners[row.ID] { chats = append(chats, row) } } r.chats = chats - tree = tasksTreeOf(kept, r.now, r.order, chats...) + tree := tasksTreeOf(kept, r.now, r.order, chats...) tree.keepConversationStates(p.reading.tree()) r.shape = &tree return r @@ -742,12 +734,15 @@ func (a *app) tasksFiltered() tasksReading { // tasksMatches asks the query of one row. // -// ANOTHER WINDOW'S ROW IS ASKED ITS TITLE AND NOTHING ELSE. Its id is +// Another window's row matches its name and owner, but its id is // deliberately not matched: ids restart with every conversation (session's // task_index.go says so on TaskIndexEntry.ID), so "7" typed here is somebody // quoting a number they read in THIS window, and answering it with another // window's seventh node would hand them the wrong task under the right number. func tasksMatches(item tasksItem, needle string) bool { + if session.TaskWordsMatch(tasksLabel(item.entry), needle) { + return true + } if item.away { return session.TaskWordsMatch(item.entry.Title+" "+item.row.Title+" "+item.row.Project, needle) } diff --git a/internal/tui3/sessionsfilter_test.go b/internal/tui3/sessionsfilter_test.go new file mode 100644 index 0000000000..d90b19dadf --- /dev/null +++ b/internal/tui3/sessionsfilter_test.go @@ -0,0 +1,56 @@ +package tui3 + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestSessionsFilterKeepsWholeConversationsForEachKindOfName(t *testing.T) { + for _, query := range []string{"Shipping the Gate", "codeaf", "/work/codeaf", "token table", "port the parser", "TOKEN TABLE", "QA acceptance"} { + t.Run(query, func(t *testing.T) { + a := tasksChatApp(t) + for i := range a.taskSheet.reading.chats { + a.taskSheet.reading.chats[i].Workspace = "/work/" + a.taskSheet.reading.chats[i].Project + } + for i := range a.taskSheet.reading.items { + item := &a.taskSheet.reading.items[i] + item.row.Workspace = "/work/" + item.row.Project + if item.entry.ID == "4" { + item.entry.Label = "QA acceptance" + } + } + a.taskSheet.query.setText(query) + r := a.tasksFiltered() + if len(r.tree().groups) != 1 || r.tree().groups[0].chat.row.ID != "room-a" { + t.Fatalf("query %q did not select just its owning conversation", query) + } + if len(r.items) != 4 || tasksWorkRows(r.lay(120)) != 4 { + t.Fatalf("query %q fragmented its conversation: %d tasks", query, len(r.items)) + } + if r.tree().up[tasksKey{session: "room-a", id: "4"}].id != "3" { + t.Fatal("filter lost the nested task's parent") + } + }) + } +} + +func TestSessionsFilterFindsTasklessChatsAndTheirProjectPath(t *testing.T) { + a := tasksChatApp(t) + now := a.now() + row := session.SessionRow{ID: "empty", Transcript: "/journals/empty/transcript.jsonl", Title: "Investigate deployment", Project: "service", Workspace: "/work/service", At: now} + world := session.World{Projects: []session.Project{{Name: "service", Sessions: []session.SessionRow{row}}}} + a.taskSheet.reading = readTasks(world, tasksMine{}, session.LastDays(now, 14), tasksSort{}, time.Time{}, now) + for _, query := range []string{"deployment", "service", "/work/service", "no-such-conversation"} { + a.taskSheet.query.setText(query) + r := a.tasksFiltered() + want := 1 + if query == "no-such-conversation" { + want = 0 + } + if len(r.tree().groups) != want || len(r.items) != 0 { + t.Fatalf("query %q: %d conversations, %d tasks", query, len(r.tree().groups), len(r.items)) + } + } +} diff --git a/internal/tui3/tasksconversation_test.go b/internal/tui3/tasksconversation_test.go index ca3da23ad2..131f4be5b0 100644 --- a/internal/tui3/tasksconversation_test.go +++ b/internal/tui3/tasksconversation_test.go @@ -420,7 +420,7 @@ func TestTheCursorKeepsAConversationAcrossARebuild(t *testing.T) { // work it was cut from, and out from under the conversation that asked for it, // is a hit with the only thing that explains it taken away — and a hit left // behind a fold nobody opened is a query that appears to have found nothing. -func TestAQueryKeepsTheAncestorsOfWhatItFoundAndOpensThePath(t *testing.T) { +func TestAQueryKeepsTheWholeConversationAndOpensItsTree(t *testing.T) { a := tasksChatApp(t) a.taskSheet.query.setText("token table") a.taskSheetTyped() @@ -428,13 +428,13 @@ func TestAQueryKeepsTheAncestorsOfWhatItFoundAndOpensThePath(t *testing.T) { r := a.tasksFiltered() width, _ := a.size() page := tasksPage(r, width) - for _, want := range []string{"Shipping the Gate", "port the parser", "port the lexer", "port the token table"} { + for _, want := range []string{"Shipping the Gate", "rotate the certificate", "port the parser", "port the lexer", "port the token table"} { if !strings.Contains(page, want) { t.Fatalf("the query lost %q from\n%s", want, page) } } - // AND NOTHING ELSE CAME WITH THEM. - for _, gone := range []string{"rotate the certificate", "Thor Clips", "render the fight clip"} { + // Other conversations stay out, even when they reuse the same task numbers. + for _, gone := range []string{"Thor Clips", "render the fight clip"} { if strings.Contains(page, gone) { t.Fatalf("the query kept %q, which matches nothing:\n%s", gone, page) } From a521f34c21caeecccac7009f0d732760e5f8941d Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:01:09 -0400 Subject: [PATCH 2/4] Keep old-task search questions reaching the Sessions manual --- internal/manual/chat/tasks.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 5d257a77bc..72ab47ca8e 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -2896,7 +2896,14 @@ wisp · /Users/ada/code/wisp - Inside a task this is absent, like the rest of it: a task sees the pieces it handed out itself and nothing wider. -## Filter Sessions by conversation name, project or task name — searching the task page, where the words I type appear, my cursor jumped to another task while I was reading +## Filter Sessions by conversation name, project or task name + +Type in Sessions to find conversations by their name, project name or path, or any +nested task name. A match keeps the complete conversation and all its tasks, rather +than hiding unmatched siblings or children. Conversations without tasks can match +their name or project too. Clear the query to restore your previous folds. + +## Searching the task page: type to filter, find an old task by name, where the words I type appear, my cursor jumped to another task while I was reading **Just type.** On Sessions every printable key — letters, the space, and digits everywhere they are not an answer — builds a filter. It selects whole conversations From b70cf42a1e0db6e5de075a02761abdb17ed7fe02 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:03:17 -0400 Subject: [PATCH 3/4] Expect full matching conversations in existing filter regressions --- internal/tui3/taskaway_test.go | 4 ++-- internal/tui3/taskview_test.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/tui3/taskaway_test.go b/internal/tui3/taskaway_test.go index 39078447b0..b331be2e77 100644 --- a/internal/tui3/taskaway_test.go +++ b/internal/tui3/taskaway_test.go @@ -470,8 +470,8 @@ func TestTheFilterReachesAnotherWindowsWork(t *testing.T) { if !strings.Contains(page, "Port the parser") { t.Fatalf("the filter lost the row it matched:\n%s", page) } - if strings.Contains(page, "Sweep the call sites") { - t.Fatalf("the filter did not reach another window's rows:\n%s", page) + if !strings.Contains(page, "Sweep the call sites") { + t.Fatalf("the filter split another window's matching conversation:\n%s", page) } } diff --git a/internal/tui3/taskview_test.go b/internal/tui3/taskview_test.go index 02078c44ac..de8433a77c 100644 --- a/internal/tui3/taskview_test.go +++ b/internal/tui3/taskview_test.go @@ -849,7 +849,7 @@ func TestTypingOnTheTaskPageFiltersBothSections(t *testing.T) { } // "port" is in one live title and one row of another conversation's work, so - // both sections survive it and everything else goes. + // both conversations survive it with all their tasks. drive(t, a, key("p"), key("o"), key("r"), key("t")) text := taskSheetText(a) for _, want := range []string{ @@ -863,9 +863,9 @@ func TestTypingOnTheTaskPageFiltersBothSections(t *testing.T) { t.Fatalf("the filtered page is missing %q:\n%s", want, text) } } - for _, gone := range []string{"Write the tree", "Mix the audio"} { - if strings.Contains(text, gone) { - t.Fatalf("%q survived the filter:\n%s", gone, text) + for _, sibling := range []string{"Write the tree", "Mix the audio"} { + if !strings.Contains(text, sibling) { + t.Fatalf("matching conversation lost %q:\n%s", sibling, text) } } From 60baadbc2f5cd3914fab006e2f1dd8404f7b2ae2 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:17:55 -0400 Subject: [PATCH 4/4] Record whole-conversation Sessions filtering for PR 1434 --- .../unreleased/1434-sessions-conversation-filter.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/changes/unreleased/1434-sessions-conversation-filter.md diff --git a/docs/changes/unreleased/1434-sessions-conversation-filter.md b/docs/changes/unreleased/1434-sessions-conversation-filter.md new file mode 100644 index 0000000000..87d858b09d --- /dev/null +++ b/docs/changes/unreleased/1434-sessions-conversation-filter.md @@ -0,0 +1,9 @@ +--- +kind: changed +title: Sessions filters whole conversations by name, project or nested task +pr: 1434 +surface: [chat, docs] +invalidates: + - "Filtering Sessions by a task name kept only matching tasks and their ancestors. It now keeps the entire owning conversation, including unmatched siblings and descendants." + - "Project paths and task display labels could fail to find their conversations. Sessions now matches those alongside conversation names, project names and full task titles, including conversations without tasks." +---