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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/changes/unreleased/1434-sessions-conversation-filter.md
Original file line number Diff line number Diff line change
@@ -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."
---
5 changes: 3 additions & 2 deletions internal/manual/chat/places.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,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 **memory** the head row echoes the filter in place of
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 **memory** the head row echoes the filter in place of
`type to filter`. Spend and standing take no text.

**Two spaces still open home from every place.** On a place with a filter they are typed
Expand Down
32 changes: 21 additions & 11 deletions internal/manual/chat/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -2965,12 +2965,20 @@ 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

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 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 ↓
Expand All @@ -2981,16 +2989,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.
Expand Down Expand Up @@ -5419,8 +5427,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.

Expand Down
57 changes: 26 additions & 31 deletions internal/tui3/place_sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,51 +386,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
Expand Down Expand Up @@ -725,12 +717,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)
}
Expand Down
56 changes: 56 additions & 0 deletions internal/tui3/sessionsfilter_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
4 changes: 2 additions & 2 deletions internal/tui3/taskaway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
8 changes: 4 additions & 4 deletions internal/tui3/tasksconversation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,21 +420,21 @@ 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()

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)
}
Expand Down
8 changes: 4 additions & 4 deletions internal/tui3/taskview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,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{
Expand All @@ -844,9 +844,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)
}
}

Expand Down
Loading