From abc302c1e6b959491fa89feabc3feba954f731d5 Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Tue, 13 Oct 2026 14:13:02 +0100 Subject: [PATCH] feat(web): thread, agent, and usage filters count and narrow by the other filters The thread attach picker's Project and Provider menus, the agent detail panel's Status, Kind, and Show menus, and the agents list's Status menu now work like the Granola picker: each choice and its count come from the list under the search and every other filter, a picked value the others exclude stays at 0, highlighted rows offer Only (which closes the menu), and applied filters show as removable pills instead of a count on the trigger. The usage breakdown's provider select lists only providers the header shows that have usage (or are loading), and the header Environment and Provider menus offer Only. Co-Authored-By: Claude Opus 5.5 --- .../src/components/chat/AgentDetailPanel.tsx | 317 +++++++++++------- .../src/components/chat/AgentListToolbar.tsx | 183 ++++++---- apps/web/src/components/chat/AgentsPanel.tsx | 11 +- .../components/chat/ThreadAttachPicker.tsx | 53 ++- .../chat/ThreadAttachPickerFilters.tsx | 279 +++++++++------ .../chat/ThreadRelationshipsControl.tsx | 10 +- .../components/chat/agentTranscript.test.ts | 15 + .../src/components/chat/agentTranscript.ts | 19 ++ .../chat/composerThreadReferences.test.ts | 61 ++++ .../chat/composerThreadReferences.ts | 105 ++++-- apps/web/src/components/usage/UsagePage.tsx | 45 ++- docs/fork-differences.md | 5 +- .../src/state/agentListView.test.ts | 86 +++++ .../client-runtime/src/state/agentListView.ts | 83 ++++- 14 files changed, 909 insertions(+), 363 deletions(-) diff --git a/apps/web/src/components/chat/AgentDetailPanel.tsx b/apps/web/src/components/chat/AgentDetailPanel.tsx index 7dbef01d6ea38..2bd8353a84b4e 100644 --- a/apps/web/src/components/chat/AgentDetailPanel.tsx +++ b/apps/web/src/components/chat/AgentDetailPanel.tsx @@ -25,6 +25,7 @@ import { DEFAULT_SUBAGENT_TOOL_CALL_VIEW, deriveSubagentToolCalls, subagentEmptyToolCallsText, + subagentToolCallFacets, subagentWorkspaceRoot, type SubagentToolCall, type SubagentToolCallSort, @@ -48,6 +49,7 @@ import { useNavigate } from "@tanstack/react-router"; import { ArrowDownUpIcon, BotIcon, + CircleDotIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, @@ -92,6 +94,8 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { buildThreadRouteParams } from "~/threadRoutes"; import { AgentElapsed, STATUS_VISUALS, StatusDot } from "../AgentStatus"; +import { FilterPill } from "../FilterPill"; +import { MenuChoiceLabel } from "../MenuChoiceLabel"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { @@ -113,7 +117,7 @@ import { agentReferenceTargetOf, attachAgentToChat, useAgentHandle } from "./age import { AgentRow, useEnvironmentShells, useProviderEntries } from "./AgentFleetRow"; import { AgentTranscriptList } from "./AgentTranscriptList"; import { - agentTranscriptKindOf, + agentTranscriptKindCounts, applyAgentTranscriptView, DEFAULT_AGENT_TRANSCRIPT_VIEW, deriveAgentTranscriptRows, @@ -121,7 +125,7 @@ import { type AgentTranscriptRowCache, type AgentTranscriptView, } from "./agentTranscript"; -import { TOOL_KIND_LABELS } from "./agentToolKinds"; +import { TOOL_KIND_ICONS, TOOL_KIND_LABELS } from "./agentToolKinds"; import { agentMenuTargetOf, showAgentInPanel, @@ -292,18 +296,17 @@ function ShowingCount(props: { shown: number; total: number }) { ); } -function countBy(items: ReadonlyArray, key: (item: T) => K): Map { - const counts = new Map(); - for (const item of items) counts.set(key(item), (counts.get(key(item)) ?? 0) + 1); - return counts; -} - +/** + * One filter's choices with how many rows each would show under the search and the other + * filters, and "Only". Choices without rows are left out unless picked. + */ function CheckboxGroup(props: { label: string; labels: Record; selected: ReadonlyArray; - counts: Map; + counts: ReadonlyMap; onChange: (next: ReadonlyArray) => void; + onOnly: (key: K) => void; }) { const keys = (Object.keys(props.labels) as K[]).filter((key) => props.counts.has(key)); if (keys.length === 0) return null; @@ -320,9 +323,11 @@ function CheckboxGroup(props: { ) } > - {props.labels[key]} - - {props.counts.get(key)} + + props.onOnly(key)} /> + + {props.counts.get(key)} + ))} @@ -330,21 +335,26 @@ function CheckboxGroup(props: { ); } -function FilterMenu(props: { activeCount: number; children: ReactNode }) { +/** The applied filters show as pills below the toolbar, so the trigger carries no count. */ +function FilterMenu(props: { + active: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; +}) { return ( - + 0 ? "micro" : "icon-micro"} - variant={props.activeCount > 0 ? "ghost" : "ghost-muted"} + size="icon-micro" + variant={props.active ? "ghost" : "ghost-muted"} aria-label="Filter activity" /> } > - {props.activeCount > 0 ? props.activeCount : null} {props.children} @@ -481,6 +491,8 @@ export function AgentDetailPanel(props: { [ownItems, props.workspaceRoot], ); const visibleCalls = useMemo(() => applySubagentToolCallView(calls, toolView), [calls, toolView]); + const toolFacets = useMemo(() => subagentToolCallFacets(calls, toolView), [calls, toolView]); + const [filterMenuOpen, setFilterMenuOpen] = useState(false); // A tool call this view was opened on (from an agent preview), expanded and scrolled to. const pendingToolCall = useAgentDrillStore((state) => @@ -545,6 +557,10 @@ export function AgentDetailPanel(props: { () => applyAgentTranscriptView(transcriptRows, transcriptView), [transcriptRows, transcriptView], ); + const transcriptKindCounts = useMemo( + () => agentTranscriptKindCounts(transcriptRows, transcriptView), + [transcriptRows, transcriptView], + ); // The agents this agent started, from its own projection's records. const children = useMemo( @@ -673,6 +689,15 @@ export function AgentDetailPanel(props: { query.length > 0 || activeFilters > 0 || (tools && toolView.sort !== DEFAULT_SUBAGENT_TOOL_CALL_VIEW.sort); + // "Only" settles the filter, so it also closes the menu. + const pickOnlyTools = (next: SubagentToolCallView) => { + setToolView(next); + setFilterMenuOpen(false); + }; + const pickOnlyTranscript = (next: AgentTranscriptView) => { + setTranscriptView(next); + setFilterMenuOpen(false); + }; const setQuery = (value: string) => tools ? setToolView({ ...toolView, query: value }) @@ -917,118 +942,180 @@ export function AgentDetailPanel(props: { {childThreadId === null ? null : ( -
- - - } - > - {tools ? "Tool calls" : "Transcript"} - {(tools ? calls.length : transcriptRows.length) > 0 ? ( - - · {tools ? calls.length : transcriptRows.length} - - ) : null} - - - - { - if (value === "transcript" || value === "tools") setMode(value); - }} - > - Tool calls - Transcript - - - -
- setQuery(event.target.value)} - /> -
- - {tools ? ( - <> - call.status)} - onChange={(statuses) => setToolView({ ...toolView, statuses })} - /> - - - label="Kind" - labels={TOOL_KIND_LABELS} - selected={toolView.kinds} - counts={countBy(calls, (call) => call.kind)} - onChange={(kinds) => setToolView({ ...toolView, kinds })} - /> - - ) : ( - setTranscriptView({ ...transcriptView, kinds })} - /> - )} - - {tools ? ( +
+
} > - + {tools ? "Tool calls" : "Transcript"} + {(tools ? calls.length : transcriptRows.length) > 0 ? ( + + · {tools ? calls.length : transcriptRows.length} + + ) : null} + - + setToolView({ ...toolView, sort })} + value={mode} + onValueChange={(value) => { + if (value === "transcript" || value === "tools") setMode(value); + }} > - {(Object.keys(TOOL_SORT_LABELS) as SubagentToolCallSort[]).map((sort) => ( - - {TOOL_SORT_LABELS[sort]} - - ))} + Tool calls + Transcript - ) : null} - {customised ? ( -
+ 0} + open={filterMenuOpen} + onOpenChange={setFilterMenuOpen} > - - + {tools ? ( + <> + setToolView({ ...toolView, statuses })} + onOnly={(status) => pickOnlyTools({ ...toolView, statuses: [status] })} + /> + + + label="Kind" + labels={TOOL_KIND_LABELS} + selected={toolView.kinds} + counts={toolFacets.kinds} + onChange={(kinds) => setToolView({ ...toolView, kinds })} + onOnly={(kind) => pickOnlyTools({ ...toolView, kinds: [kind] })} + /> + + ) : ( + setTranscriptView({ ...transcriptView, kinds })} + onOnly={(kind) => pickOnlyTranscript({ ...transcriptView, kinds: [kind] })} + /> + )} + + {tools ? ( + + + } + > + + + + + setToolView({ ...toolView, sort }) + } + > + {(Object.keys(TOOL_SORT_LABELS) as SubagentToolCallSort[]).map((sort) => ( + + {TOOL_SORT_LABELS[sort]} + + ))} + + + + ) : null} + {customised ? ( + + ) : null} +
+ {activeFilters > 0 ? ( +
+ {tools ? ( + <> + {toolView.statuses.map((status) => ( + } + label={TOOL_STATUS_LABELS[status]} + onRemove={() => + setToolView({ + ...toolView, + statuses: toolView.statuses.filter((entry) => entry !== status), + }) + } + /> + ))} + {toolView.kinds.map((kind) => { + const KindIcon = TOOL_KIND_ICONS[kind]; + return ( + } + label={TOOL_KIND_LABELS[kind]} + onRemove={() => + setToolView({ + ...toolView, + kinds: toolView.kinds.filter((entry) => entry !== kind), + }) + } + /> + ); + })} + + ) : ( + transcriptView.kinds.map((kind) => ( + } + label={TRANSCRIPT_KIND_LABELS[kind]} + onRemove={() => + setTranscriptView({ + ...transcriptView, + kinds: transcriptView.kinds.filter((entry) => entry !== kind), + }) + } + /> + )) + )} +
) : null}
)} diff --git a/apps/web/src/components/chat/AgentListToolbar.tsx b/apps/web/src/components/chat/AgentListToolbar.tsx index 5b91453316843..3736cc7f00ed9 100644 --- a/apps/web/src/components/chat/AgentListToolbar.tsx +++ b/apps/web/src/components/chat/AgentListToolbar.tsx @@ -4,13 +4,17 @@ import { type AgentListView, type AgentStatusFilter, } from "@t3tools/client-runtime/state/agent-list-view"; -import { ArrowDownUpIcon, ListFilterIcon, XIcon } from "lucide-react"; +import { ArrowDownUpIcon, CircleDotIcon, ListFilterIcon, XIcon } from "lucide-react"; +import { useState } from "react"; +import { FilterPill } from "../FilterPill"; +import { MenuChoiceLabel } from "../MenuChoiceLabel"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Menu, MenuCheckboxItem, + MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, @@ -32,12 +36,21 @@ const SORT_LABELS: Record = { duration: "Duration", }; -/** Fork: search, status filter, and sort for a thread's agents list. */ +/** + * Fork: search, status filter, and sort for a thread's agents list. Each status lists how many + * agents it would show under the search (`agentListStatusCounts`); picked statuses show as pills + * below. + */ export function AgentListToolbar(props: { view: AgentListView; + statusCounts: ReadonlyMap; onChange: (view: AgentListView) => void; }) { const { view } = props; + const [statusMenuOpen, setStatusMenuOpen] = useState(false); + const statuses = (Object.keys(STATUS_LABELS) as AgentStatusFilter[]).filter((status) => + props.statusCounts.has(status), + ); const toggleStatus = (status: AgentStatusFilter, checked: boolean) => props.onChange({ ...view, @@ -47,78 +60,104 @@ export function AgentListToolbar(props: { }); return ( -
- props.onChange({ ...view, query: event.target.value })} - className="min-w-0 flex-1" - /> - - 0 ? "outline" : "ghost"} - aria-label="Filter agents by status" - /> - } - > - - {view.statuses.length > 0 ? view.statuses.length : null} - - - {(Object.keys(STATUS_LABELS) as AgentStatusFilter[]).map((status) => ( - toggleStatus(status, checked)} - > - {STATUS_LABELS[status]} - - ))} - - - - - } - > - - - - props.onChange({ ...view, sort })} +
+
+ props.onChange({ ...view, query: event.target.value })} + className="min-w-0 flex-1" + /> + + 0 ? "outline" : "ghost"} + aria-label="Filter agents by status" + /> + } > - {(Object.keys(SORT_LABELS) as AgentListSort[]).map((sort) => ( - - {SORT_LABELS[sort]} - + + + + {statuses.length === 0 ? No agents match : null} + {statuses.map((status) => ( + toggleStatus(status, checked)} + > + + { + // "Only" settles the filter, so it also closes the menu. + props.onChange({ ...view, statuses: [status] }); + setStatusMenuOpen(false); + }} + /> + + {props.statusCounts.get(status)} + + + ))} - - - - {view.statuses.length > 0 || view.query.length > 0 || view.sort !== "spawn" ? ( - + +
+ + + } + > + + + + props.onChange({ ...view, sort })} + > + {(Object.keys(SORT_LABELS) as AgentListSort[]).map((sort) => ( + + {SORT_LABELS[sort]} + + ))} + + + + {view.statuses.length > 0 || view.query.length > 0 || view.sort !== "spawn" ? ( + + ) : null} +
+ {view.statuses.length > 0 ? ( +
+ {view.statuses.map((status) => ( + } + label={STATUS_LABELS[status]} + onRemove={() => toggleStatus(status, false)} + /> + ))} +
) : null} ); diff --git a/apps/web/src/components/chat/AgentsPanel.tsx b/apps/web/src/components/chat/AgentsPanel.tsx index ca63f63f33983..a8db5b3df4ad0 100644 --- a/apps/web/src/components/chat/AgentsPanel.tsx +++ b/apps/web/src/components/chat/AgentsPanel.tsx @@ -23,6 +23,7 @@ import { type AgentFleetEntry, } from "@t3tools/client-runtime/state/agent-fleet"; import { + agentListStatusCounts, isAgentListViewFiltered, type AgentStatusFilter, } from "@t3tools/client-runtime/state/agent-list-view"; @@ -228,6 +229,14 @@ function AgentFleetList(props: AgentsPanelProps) { [fleet, threadRef.threadId, view], ); const summary = useMemo(() => summarizeAgentFleet(fleet), [fleet]); + const statusCounts = useMemo( + () => + agentListStatusCounts( + fleet.map((entry) => entry.subject), + view, + ), + [fleet, view], + ); const filtered = isAgentListViewFiltered(view); const visibleRows = rows.slice(0, visibleCount); const hiddenCount = rows.length - visibleRows.length; @@ -277,7 +286,7 @@ function AgentFleetList(props: AgentsPanelProps) {
- +
diff --git a/apps/web/src/components/chat/ThreadAttachPicker.tsx b/apps/web/src/components/chat/ThreadAttachPicker.tsx index a6799fc4fda35..c7130817a3a5c 100644 --- a/apps/web/src/components/chat/ThreadAttachPicker.tsx +++ b/apps/web/src/components/chat/ThreadAttachPicker.tsx @@ -3,7 +3,12 @@ import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { listThreadTabMemberships } from "@t3tools/client-runtime/thread-tabs"; import type { PreparedConnection } from "@t3tools/client-runtime/connection"; -import type { ScopedThreadRef, ThreadId, ThreadTabMembership } from "@t3tools/contracts"; +import type { + ProviderInstanceId, + ScopedThreadRef, + ThreadId, + ThreadTabMembership, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import { Atom } from "effect/reactivity"; import { MessageSquareIcon } from "lucide-react"; @@ -34,6 +39,7 @@ import { composerThreadReferenceItems, DEFAULT_THREAD_ATTACH_PICKER_VIEW, groupThreadAttachPickerItems, + threadAttachPickerFacets, } from "./composerThreadReferences"; import { CursorPreviewCard } from "./CursorPreviewCard"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; @@ -168,34 +174,27 @@ function ThreadAttachPickerDialog({ threadRef }: { threadRef: ScopedThreadRef }) return thread ? projectsById.get(thread.projectId) : undefined; }; const filterOptions = useMemo(() => { - const availableThreads = threads.filter( - (thread) => - thread.environmentId === threadRef.environmentId && - thread.archivedAt === null && - thread.id !== threadRef.threadId, - ); - const projectIds = new Set(availableThreads.map((thread) => thread.projectId)); - const providers = new Map( - availableThreads.map((thread) => [ - thread.modelSelection.instanceId, - providerEntries.get(thread.modelSelection.instanceId)?.displayName ?? - thread.runtime?.providerName ?? - thread.modelSelection.instanceId, - ]), - ); + const facets = threadAttachPickerFacets({ + threads, + projects, + environmentId: threadRef.environmentId, + excludeThreadIds: new Set([threadRef.threadId]), + query, + view, + ...(memberships ? { tabMemberships: memberships } : {}), + }); + const providerName = (value: ProviderInstanceId) => + providerEntries.get(value)?.displayName ?? + threads.find((thread) => thread.modelSelection.instanceId === value)?.runtime?.providerName ?? + value; return { - projects: projects - .filter( - (project) => - project.environmentId === threadRef.environmentId && projectIds.has(project.id), - ) - .map((project) => ({ value: project.id, label: project.title })) - .toSorted((left, right) => left.label.localeCompare(right.label)), - providers: [...providers] - .map(([value, label]) => ({ value, label })) - .toSorted((left, right) => left.label.localeCompare(right.label)), + projects: facets.projects, + providers: facets.providers.map((choice) => ({ + ...choice, + label: providerName(choice.value), + })), }; - }, [threads, projects, threadRef, providerEntries]); + }, [threads, projects, threadRef, query, view, memberships, providerEntries]); async function select(entry: (typeof entries)[number]) { if (attachInFlight.current || captureContext === null || loadSummary === null) return; diff --git a/apps/web/src/components/chat/ThreadAttachPickerFilters.tsx b/apps/web/src/components/chat/ThreadAttachPickerFilters.tsx index 016c2277013c7..215b5b91d8967 100644 --- a/apps/web/src/components/chat/ThreadAttachPickerFilters.tsx +++ b/apps/web/src/components/chat/ThreadAttachPickerFilters.tsx @@ -1,11 +1,14 @@ import type { ProjectId, ProviderInstanceId } from "@t3tools/contracts"; -import { ArrowDownUpIcon, ListFilterIcon } from "lucide-react"; +import { ArrowDownUpIcon, BotIcon, FolderIcon, ListFilterIcon } from "lucide-react"; +import { useState } from "react"; +import { FilterPill } from "../FilterPill"; +import { MenuChoiceLabel } from "../MenuChoiceLabel"; import { Button } from "../ui/button"; -import { HighlightedSearchText } from "../ui/highlighted-search-text"; import { Menu, MenuCheckboxItem, + MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, @@ -27,125 +30,185 @@ const SORT_LABELS = { title: "Title A–Z", } as const satisfies Record; +type Choice = { + readonly value: T; + readonly label: string; + readonly count: number; +}; + function toggle(values: ReadonlyArray, value: T, checked: boolean) { return checked ? [...values.filter((entry) => entry !== value), value] : values.filter((entry) => entry !== value); } -/** Project and provider filters share the same local view as the picker's search and sort. */ +/** A searchable checklist with each choice's thread count and "Only". */ +function ChoiceChecklist(props: { + choices: ReadonlyArray>; + selected: ReadonlyArray; + placeholder: string; + empty: string; + onChange: (selected: ReadonlyArray) => void; + onOnly: (value: T) => void; +}) { + if (props.choices.length === 0) return {props.empty}; + return ( + choice.label} + placeholder={props.placeholder} + > + {(matches, query) => + matches.map((choice) => ( + + props.onChange(toggle(props.selected, choice.value, checked)) + } + > + + props.onOnly(choice.value)} + /> + + {choice.count} + + + + )) + } + + ); +} + +/** + * Project and provider filters share the same local view as the picker's search and sort. Each + * applied selection shows as a pill on its own row below, with an × to drop it. + */ export function ThreadAttachPickerFilterBar(props: { - projects: ReadonlyArray<{ value: ProjectId; label: string }>; - providers: ReadonlyArray<{ value: ProviderInstanceId; label: string }>; + projects: ReadonlyArray>; + providers: ReadonlyArray>; view: ThreadAttachPickerView; onChange: (view: ThreadAttachPickerView) => void; }) { const { view } = props; - const filterCount = view.projectIds.length + view.providerInstanceIds.length; + const [menuOpen, setMenuOpen] = useState(false); + // "Only" settles the filter, so it also closes the menu. + const pickOnly = (next: ThreadAttachPickerView) => { + props.onChange(next); + setMenuOpen(false); + }; + const filtered = view.projectIds.length > 0 || view.providerInstanceIds.length > 0; + const labelOf = (choices: ReadonlyArray>, value: T) => + choices.find((choice) => choice.value === value)?.label ?? value; return ( -
- - }> - - {filterCount > 0 ? `Filter (${filterCount})` : "Filter"} - - - - - Project{view.projectIds.length > 0 ? ` (${view.projectIds.length})` : ""} - - - project.label} - placeholder="Search projects" - > - {(matches, query) => - matches.map((project) => ( - - props.onChange({ - ...view, - projectIds: toggle(view.projectIds, project.value, checked), - }) - } - > - - - )) - } - - - - - - Provider - {view.providerInstanceIds.length > 0 ? ` (${view.providerInstanceIds.length})` : ""} - - - provider.label} - placeholder="Search providers" - > - {(matches, query) => - matches.map((provider) => ( - - props.onChange({ - ...view, - providerInstanceIds: toggle( - view.providerInstanceIds, - provider.value, - checked, - ), - }) - } - > - - - )) - } - - - - - -
- {filterCount > 0 || view.sort !== DEFAULT_THREAD_ATTACH_PICKER_VIEW.sort ? ( - - ) : null} - - }> - - {SORT_LABELS[view.sort]} - - - - props.onChange({ ...view, sort }) - } +
+
+ + }> + + Filter + + + + Project + + props.onChange({ ...view, projectIds })} + onOnly={(projectId) => pickOnly({ ...view, projectIds: [projectId] })} + /> + + + + Provider + + + props.onChange({ ...view, providerInstanceIds }) + } + onOnly={(providerInstanceId) => + pickOnly({ ...view, providerInstanceIds: [providerInstanceId] }) + } + /> + + + + +
+ {filtered || view.sort !== DEFAULT_THREAD_ATTACH_PICKER_VIEW.sort ? ( +
+ Reset + + ) : null} + + }> + + {SORT_LABELS[view.sort]} + + + + props.onChange({ ...view, sort }) + } + > + {(Object.keys(SORT_LABELS) as Array).map((sort) => ( + + {SORT_LABELS[sort]} + + ))} + + + +
+ {filtered ? ( +
+ {view.projectIds.map((projectId) => ( + } + label={labelOf(props.projects, projectId)} + onRemove={() => + props.onChange({ + ...view, + projectIds: view.projectIds.filter((entry) => entry !== projectId), + }) + } + /> + ))} + {view.providerInstanceIds.map((providerInstanceId) => ( + } + label={labelOf(props.providers, providerInstanceId)} + onRemove={() => + props.onChange({ + ...view, + providerInstanceIds: view.providerInstanceIds.filter( + (entry) => entry !== providerInstanceId, + ), + }) + } + /> + ))} +
+ ) : null}
); } diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 221746b7c2ef7..11e6dd4066f06 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -7,6 +7,7 @@ import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { + agentListStatusCounts, applyAgentListView, isAgentListViewFiltered, type AgentListSubject, @@ -517,7 +518,14 @@ export function ThreadRelationshipsPanel(props: { } > {showAgentToolbar ? ( - + ) : null} {showAgentToolbar && agentListFiltered && diff --git a/apps/web/src/components/chat/agentTranscript.test.ts b/apps/web/src/components/chat/agentTranscript.test.ts index bad1e7c178363..790ff84ef0094 100644 --- a/apps/web/src/components/chat/agentTranscript.test.ts +++ b/apps/web/src/components/chat/agentTranscript.test.ts @@ -18,6 +18,7 @@ import { import { subagentWorkspaceRoot } from "@t3tools/client-runtime/state/agent-list-view"; import { + agentTranscriptKindCounts, agentTranscriptToolRowIndex, applyAgentTranscriptView, deriveAgentTranscriptRows, @@ -210,4 +211,18 @@ describe("applyAgentTranscriptView", () => { applyAgentTranscriptView(rows, { query: "SESSION", kinds: [] }).map((row) => row.id), ).toEqual([thinking.id, edit.id, helper.id, "answer"]); }); + + it("counts kinds under the search, keeping a picked kind nothing matches at 0", () => { + expect([...agentTranscriptKindCounts(rows, { query: "", kinds: ["tool"] })]).toEqual([ + ["message", 2], + ["tool", 2], + ["reasoning", 1], + ]); + expect([ + ...agentTranscriptKindCounts(rows, { query: "expires", kinds: ["reasoning"] }), + ]).toEqual([ + ["tool", 1], + ["reasoning", 0], + ]); + }); }); diff --git a/apps/web/src/components/chat/agentTranscript.ts b/apps/web/src/components/chat/agentTranscript.ts index e92cb4d761370..9a960656ef2d5 100644 --- a/apps/web/src/components/chat/agentTranscript.ts +++ b/apps/web/src/components/chat/agentTranscript.ts @@ -8,6 +8,7 @@ import { type SubagentToolKind, } from "@t3tools/client-runtime/state/agent-list-view"; import type { OrchestrationV2ProjectedTurnItem, ThreadId, TurnItemId } from "@t3tools/contracts"; +import { filterFacets } from "@t3tools/shared/filterFacets"; import type { TimelineEntry, WorkLogEntry } from "../../session-logic"; import { workEntryDisplayLabel, workEntryIsVisibleInGroup } from "./MessagesTimeline.logic"; @@ -263,6 +264,24 @@ export function applyAgentTranscriptView( ); } +/** + * How many rows each kind would show under the search. A picked kind nothing matches stays at 0, + * so it can be unpicked; other kinds without rows are absent. + */ +export function agentTranscriptKindCounts( + rows: ReadonlyArray, + view: AgentTranscriptView, +): ReadonlyMap { + const searched = applyAgentTranscriptView(rows, { ...view, kinds: [] }); + const facets = filterFacets(searched, { + kinds: { + values: (row: AgentTranscriptRow) => [agentTranscriptKindOf(row)], + selected: view.kinds, + }, + }); + return new Map(facets.kinds.map((choice) => [choice.value as AgentTranscriptKind, choice.count])); +} + /** The position of a tool call's row, to open the transcript on it; -1 when it is not listed. */ export function agentTranscriptToolRowIndex( rows: ReadonlyArray, diff --git a/apps/web/src/components/chat/composerThreadReferences.test.ts b/apps/web/src/components/chat/composerThreadReferences.test.ts index 57db4ae4b34aa..acb106ec1a882 100644 --- a/apps/web/src/components/chat/composerThreadReferences.test.ts +++ b/apps/web/src/components/chat/composerThreadReferences.test.ts @@ -9,6 +9,7 @@ import { composerThreadReferenceItems, DEFAULT_THREAD_ATTACH_PICKER_VIEW, groupThreadAttachPickerItems, + threadAttachPickerFacets, } from "./composerThreadReferences"; const environmentId = EnvironmentId.make("env-1"); @@ -268,3 +269,63 @@ describe("composerThreadReferenceItems", () => { expect(items.map((entry) => entry.parentThreadTitle)).toEqual(["Renamed parent"]); }); }); + +describe("threadAttachPickerFacets", () => { + const otherProjectId = ProjectId.make("project-2"); + const claude = ProviderInstanceId.make("claude"); + const threads = [ + thread("a", "Login fix", "2026-09-01T00:00:00.000Z"), + thread("b", "Login copy", "2026-09-02T00:00:00.000Z", { + modelSelection: { instanceId: claude, model: "opus" }, + }), + thread("c", "Login docs", "2026-09-03T00:00:00.000Z", { + projectId: otherProjectId, + modelSelection: { instanceId: claude, model: "opus" }, + }), + thread("d", "Billing", "2026-09-04T00:00:00.000Z", { projectId: otherProjectId }), + thread("e", "Login current", "2026-09-05T00:00:00.000Z"), + ]; + const twoProjects = [ + ...projects, + { id: otherProjectId, environmentId, title: "docs" } as EnvironmentProject, + ]; + const facetsFor = (query: string, view = DEFAULT_THREAD_ATTACH_PICKER_VIEW) => + threadAttachPickerFacets({ + threads, + projects: twoProjects, + environmentId, + excludeThreadIds: new Set([ThreadId.make("e")]), + query, + view, + }); + + it("counts each choice under the query and the other filter, not its own", () => { + const facets = facetsFor("login", { + ...DEFAULT_THREAD_ATTACH_PICKER_VIEW, + projectIds: [projectId], + providerInstanceIds: [claude], + }); + // Project counts follow the provider filter; picking a project keeps the other offered. + expect(facets.projects).toEqual([ + { value: projectId, label: "t3code", count: 1 }, + { value: otherProjectId, label: "docs", count: 1 }, + ]); + // Provider counts follow the project filter; "Billing" is outside the query. + expect(facets.providers).toEqual([ + { value: claude, count: 1 }, + { value: ProviderInstanceId.make("codex"), count: 1 }, + ]); + }); + + it("keeps a picked choice the other filters now exclude, at zero", () => { + const facets = facetsFor("billing", { + ...DEFAULT_THREAD_ATTACH_PICKER_VIEW, + providerInstanceIds: [claude], + }); + expect(facets.providers).toEqual([ + { value: ProviderInstanceId.make("codex"), count: 1 }, + { value: claude, count: 0 }, + ]); + expect(facets.projects).toEqual([]); + }); +}); diff --git a/apps/web/src/components/chat/composerThreadReferences.ts b/apps/web/src/components/chat/composerThreadReferences.ts index 5291982eef2e9..2405550cd0e34 100644 --- a/apps/web/src/components/chat/composerThreadReferences.ts +++ b/apps/web/src/components/chat/composerThreadReferences.ts @@ -9,6 +9,11 @@ import type { ThreadId, ThreadTabMembership, } from "@t3tools/contracts"; +import { + applyFilterDimensions, + type FilterDimension, + filterFacets, +} from "@t3tools/shared/filterFacets"; export interface ThreadAttachPickerView { readonly projectIds: ReadonlyArray; @@ -26,24 +31,22 @@ export const DEFAULT_THREAD_ATTACH_PICKER_VIEW: ThreadAttachPickerView = { const THREAD_REFERENCE_LIMIT = 6; const THREAD_REFERENCE_EMPTY_QUERY_LIMIT = 3; -/** - * References to unarchived threads in the same environment, excluding the supplied IDs. - * The `@` menu defaults to recent title matches; the attach picker supplies filters and sort. - */ -export function composerThreadReferenceItems(input: { +interface ThreadReferenceInput { threads: ReadonlyArray; projects: ReadonlyArray; environmentId: EnvironmentId; excludeThreadIds: ReadonlySet; query: string; - /** Dedicated pickers can show more results than the inline mention menu. */ - limit?: number; - view?: ThreadAttachPickerView; /** Memberships belong to `environmentId`; omitted by the inline mention menu. */ tabMemberships?: ReadonlyArray; -}) { +} + +/** + * The unarchived threads of the environment that match the query, by their own title or their + * tab group's, before the picker's project and provider filters. + */ +function threadReferenceCandidates(input: ThreadReferenceInput) { const query = input.query.trim().toLowerCase(); - const view = input.view ?? DEFAULT_THREAD_ATTACH_PICKER_VIEW; const memberships = input.tabMemberships ?? []; const threadsById = new Map( memberships.length === 0 @@ -65,6 +68,44 @@ export function composerThreadReferenceItems(input: { const parent = parentsByGroupId.get(membership.groupId); if (parent) parentByThreadId.set(membership.threadId, parent); } + const candidates = input.threads.filter( + (thread) => + thread.environmentId === input.environmentId && + thread.archivedAt === null && + !input.excludeThreadIds.has(thread.id) && + (thread.title.toLowerCase().includes(query) || + parentByThreadId.get(thread.id)?.title.toLowerCase().includes(query) === true), + ); + return { query, memberships, parentByThreadId, candidates }; +} + +/** The attach picker's project and provider filters as facet dimensions. */ +function threadAttachDimensions(view: ThreadAttachPickerView) { + return { + projects: { + values: (thread: EnvironmentThreadShell) => [thread.projectId], + selected: view.projectIds, + }, + providers: { + values: (thread: EnvironmentThreadShell) => [thread.modelSelection.instanceId], + selected: view.providerInstanceIds, + }, + } satisfies Record>; +} + +/** + * References to unarchived threads in the same environment, excluding the supplied IDs. + * The `@` menu defaults to recent title matches; the attach picker supplies filters and sort. + */ +export function composerThreadReferenceItems( + input: ThreadReferenceInput & { + /** Dedicated pickers can show more results than the inline mention menu. */ + limit?: number; + view?: ThreadAttachPickerView; + }, +) { + const view = input.view ?? DEFAULT_THREAD_ATTACH_PICKER_VIEW; + const { query, memberships, parentByThreadId, candidates } = threadReferenceCandidates(input); const groupIdByThreadId = new Map( memberships.map((membership) => [membership.threadId, membership.groupId]), ); @@ -73,18 +114,7 @@ export function composerThreadReferenceItems(input: { .filter((project) => project.environmentId === input.environmentId) .map((project) => [project.id, project.title]), ); - return input.threads - .filter( - (thread) => - thread.environmentId === input.environmentId && - thread.archivedAt === null && - !input.excludeThreadIds.has(thread.id) && - (view.projectIds.length === 0 || view.projectIds.includes(thread.projectId)) && - (view.providerInstanceIds.length === 0 || - view.providerInstanceIds.includes(thread.modelSelection.instanceId)) && - (thread.title.toLowerCase().includes(query) || - parentByThreadId.get(thread.id)?.title.toLowerCase().includes(query) === true), - ) + return applyFilterDimensions(candidates, threadAttachDimensions(view)) .toSorted((left, right) => { const order = view.sort === "title" @@ -108,6 +138,37 @@ export function composerThreadReferenceItems(input: { })); } +/** + * The attach picker's project and provider choices, each with how many threads picking it would + * show under the query and the other filter (see `filterFacets`). Projects without a record are + * left out unless picked. + */ +export function threadAttachPickerFacets( + input: ThreadReferenceInput & { view: ThreadAttachPickerView }, +) { + const facets = filterFacets( + threadReferenceCandidates(input).candidates, + threadAttachDimensions(input.view), + ); + const projectTitles = new Map( + input.projects + .filter((project) => project.environmentId === input.environmentId) + .map((project) => [project.id as string, project.title]), + ); + return { + projects: facets.projects.flatMap((choice) => { + const value = choice.value as ProjectId; + const title = projectTitles.get(value); + if (title === undefined && !input.view.projectIds.includes(value)) return []; + return [{ value, label: title ?? "Unknown project", count: choice.count }]; + }), + providers: facets.providers.map((choice) => ({ + value: choice.value as ProviderInstanceId, + count: choice.count, + })), + }; +} + /** * Keeps matching tabs together, with groups ordered by their highest-ranked matching tab. * The original tab leads its group so the group reads as one thread. diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 2c384239288dc..e214e77c73af2 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -81,6 +81,7 @@ import { WorkspaceBreadcrumbSeparator, WorkspaceBreadcrumbText, } from "../WorkspaceBreadcrumb"; +import { MenuChoiceLabel } from "../MenuChoiceLabel"; import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; @@ -264,6 +265,16 @@ export function UsagePage() { ), [activeProviders, loading.everyProvider, loading.providers], ); + // The breakdown offers what the header shows with usage, and keeps a pick that has since gone. + const breakdownProviders = useMemo( + () => + PROVIDER_ORDER.filter( + (provider) => + PROVIDER_PRESENTATION[provider] !== undefined && + (activeProviders.includes(provider) || provider === providerFilter), + ), + [activeProviders, providerFilter], + ); const visibleProviders = useMemo( () => providerFilter === "all" @@ -900,9 +911,7 @@ export function UsagePage() { All providers - {PROVIDER_ORDER.filter( - (provider) => PROVIDER_PRESENTATION[provider] !== undefined, - ).map((provider) => ( + {breakdownProviders.map((provider) => ( {PROVIDER_PRESENTATION[provider].label} @@ -1459,6 +1468,7 @@ function UsageEnvironmentFilter({ readonly onOpenModelPrices: () => void; }) { const allSelected = selectedEnvironmentIds === null; + const [open, setOpen] = useState(false); const label = allSelected ? "All environments" : selectedEnvironments.length === 1 @@ -1469,7 +1479,7 @@ function UsageEnvironmentFilter({ contractMismatches.length > 0; return ( - + } className="group/usage-environment min-w-0 max-w-full"> {label} @@ -1530,8 +1540,17 @@ function UsageEnvironmentFilter({ onSelectionChange(next.size === environments.length ? null : next); }} > - - {environment.label} + + { + // "Only" settles the filter, so it also closes the menu. + onSelectionChange( + environments.length === 1 ? null : new Set([environment.environmentId]), + ); + setOpen(false); + }} + /> {showUsageStatus ? ( void; }) { const visible = PROVIDER_ORDER.filter((provider) => !hiddenProviders.has(provider)); + const [open, setOpen] = useState(false); const label = visible.length === PROVIDER_ORDER.length ? "All providers" @@ -1591,7 +1611,7 @@ function UsageProviderFilter({ : `${visible.length} providers`; return ( - + } className="group/usage-provider min-w-0 max-w-full"> {label} - + - {PROVIDER_PRESENTATION[provider].label} + { + // "Only" settles the filter, so it also closes the menu. + onChange(PROVIDER_ORDER.filter((entry) => entry !== provider)); + setOpen(false); + }} + /> ))} diff --git a/docs/fork-differences.md b/docs/fork-differences.md index 538219fee9a45..6534528b69b71 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -505,8 +505,9 @@ editable. Names are stored by the [ThreadTabs service](../apps/server/src/thread too. On web and desktop, **Attach → Thread** and **Attach thread** in the command palette open a searchable picker of those threads. A thread's tabs sit together behind a side rule, and searching a thread title finds its tabs too. Rows show the project, provider, status, and - last activity as the sidebar does. It has project and provider filters - and sorting by updated time, creation time, or title. Hovering a row previews how the chat + last activity as the sidebar does. It has project and provider filters, each choice counting + the threads it would show under the search and the other filter, with **Only** and a removable + pill per pick, and sorting by updated time, creation time, or title. Hovering a row previews how the chat started, the latest exchange, and changed files; attaching reuses that snapshot ([`ThreadAttachPicker.tsx`](../apps/web/src/components/chat/ThreadAttachPicker.tsx)). These paths insert a `thread-tab` context chip at the caret, which can be moved like any other chip. The kind lives in diff --git a/packages/client-runtime/src/state/agentListView.test.ts b/packages/client-runtime/src/state/agentListView.test.ts index f8752cfead497..98c1cb5d1e63c 100644 --- a/packages/client-runtime/src/state/agentListView.test.ts +++ b/packages/client-runtime/src/state/agentListView.test.ts @@ -3,6 +3,7 @@ import { ThreadId, TurnItemId, type OrchestrationV2TurnItem } from "@t3tools/con import * as DateTime from "effect/DateTime"; import { + agentListStatusCounts, applyAgentListView, applySubagentToolCallView, DEFAULT_AGENT_LIST_VIEW, @@ -11,6 +12,7 @@ import { latestSubagentToolCall, subagentContinuationContext, subagentEmptyToolCallsText, + subagentToolCallFacets, subagentWorkspaceRoot, type AgentListSubject, type SubagentToolCall, @@ -151,6 +153,90 @@ function item( } as OrchestrationV2TurnItem; } +describe("agentListStatusCounts", () => { + const agents = [ + agent({ title: "Scout", status: "running" }), + agent({ title: "Scout two", status: "completed" }), + agent({ title: "Builder", status: "completed" }), + ]; + + it("counts statuses under the search, not under the status filter itself", () => { + const counts = agentListStatusCounts(agents, { + ...DEFAULT_AGENT_LIST_VIEW, + statuses: ["working"], + query: "scout", + }); + expect([...counts]).toEqual([ + ["done", 1], + ["working", 1], + ]); + }); + + it("keeps a picked status at 0 when the search leaves none", () => { + const counts = agentListStatusCounts(agents, { + ...DEFAULT_AGENT_LIST_VIEW, + statuses: ["working"], + query: "builder", + }); + expect([...counts]).toEqual([ + ["done", 1], + ["working", 0], + ]); + }); +}); + +describe("subagentToolCallFacets", () => { + const call = ( + id: string, + kind: SubagentToolCall["kind"], + status: SubagentToolCall["status"], + detail: string, + ): SubagentToolCall => ({ + id: TurnItemId.make(id), + title: "Tool", + detail, + kind, + status, + startedAt: null, + completedAt: null, + exitCode: null, + preview: null, + detailRevision: null, + }); + const calls = [ + call("a", "command", "completed", "vp test"), + call("b", "command", "failed", "vp lint"), + call("c", "read", "completed", "src/a.ts"), + call("d", "read", "completed", "src/b.ts"), + ]; + + it("counts statuses under the kind filter and kinds under the status filter", () => { + const facets = subagentToolCallFacets(calls, { + ...DEFAULT_SUBAGENT_TOOL_CALL_VIEW, + statuses: ["failed"], + kinds: ["command"], + }); + expect([...facets.statuses]).toEqual([ + ["completed", 1], + ["failed", 1], + ]); + expect([...facets.kinds]).toEqual([["command", 1]]); + }); + + it("applies the search first and keeps a picked value nothing matches at 0", () => { + const facets = subagentToolCallFacets(calls, { + ...DEFAULT_SUBAGENT_TOOL_CALL_VIEW, + query: "src/", + kinds: ["command"], + }); + expect([...facets.kinds]).toEqual([ + ["read", 2], + ["command", 0], + ]); + expect([...facets.statuses]).toEqual([]); + }); +}); + describe("deriveSubagentToolCalls", () => { const root = "/repo/app"; const items = [ diff --git a/packages/client-runtime/src/state/agentListView.ts b/packages/client-runtime/src/state/agentListView.ts index c2e865ac59aef..a9937966c4cc7 100644 --- a/packages/client-runtime/src/state/agentListView.ts +++ b/packages/client-runtime/src/state/agentListView.ts @@ -2,6 +2,7 @@ * Fork: presentation over orchestration-v2 subagents, shared by every client. * * - The user's view of a thread's agents list: status filter, search, sort. + * Filter menus count each choice under the search and the other filters (`filterFacets`). * Spawn order is the default, and no sort moves a row while it works. * - A subagent's tool calls, read from its child thread's turn items, for the * compact rows' latest call and the agent tab's searchable log. @@ -11,6 +12,12 @@ import type { OrchestrationV2TurnItem, TurnItemId } from "@t3tools/contracts"; import { isWindowsAbsolutePath } from "@t3tools/shared/path"; import * as DateTime from "effect/DateTime"; import { copySorted } from "@t3tools/shared/Array"; +import { + applyFilterDimensions, + type FacetChoice, + type FilterDimension, + filterFacets, +} from "@t3tools/shared/filterFacets"; import { classifyToolActivity, collectToolFilePaths, @@ -112,6 +119,32 @@ export function matchesAgentListView(subject: AgentListSubject, view: AgentListV ); } +function facetCounts( + choices: ReadonlyArray, +): ReadonlyMap { + return new Map(choices.map((choice) => [choice.value as Value, choice.count])); +} + +/** + * How many agents each status filter would show under the search. A picked status no agent + * matches stays at 0, so it can be unpicked; other statuses without agents are absent. + */ +export function agentListStatusCounts( + subjects: ReadonlyArray, + view: AgentListView, +): ReadonlyMap { + const searched = subjects.filter((subject) => + matchesAgentListView(subject, { ...view, statuses: [] }), + ); + const facets = filterFacets(searched, { + statuses: { + values: (subject: AgentListSubject) => [agentStatusFilterFor(subject.status)], + selected: view.statuses, + }, + }); + return facetCounts(facets.statuses); +} + /** * Filters and sorts a thread's agents. Spawn order (oldest first) is the base, * and the other sorts never reshuffle a working row: status order only moves a @@ -584,17 +617,55 @@ function durationMs(call: SubagentToolCall): number { return started === null ? 0 : completed - started; } +function searchSubagentToolCalls( + calls: ReadonlyArray, + query: string, +): ReadonlyArray { + const needle = query.trim().toLocaleLowerCase(); + return needle.length === 0 + ? calls + : calls.filter((call) => subagentToolCallText(call).toLocaleLowerCase().includes(needle)); +} + +function toolCallDimensions(view: SubagentToolCallView) { + return { + statuses: { + values: (call: SubagentToolCall) => [call.status], + selected: view.statuses, + }, + kinds: { + values: (call: SubagentToolCall) => [call.kind], + selected: view.kinds, + }, + } satisfies Record>; +} + +/** + * How many calls each status and kind would show under the search and the other filter. A picked + * value nothing matches stays at 0, so it can be unpicked; other values without calls are absent. + */ +export function subagentToolCallFacets( + calls: ReadonlyArray, + view: SubagentToolCallView, +): { + readonly statuses: ReadonlyMap; + readonly kinds: ReadonlyMap; +} { + const facets = filterFacets(searchSubagentToolCalls(calls, view.query), toolCallDimensions(view)); + return { + statuses: facetCounts(facets.statuses), + kinds: facetCounts(facets.kinds), + }; +} + /** Filters, searches and sorts the log. Input is oldest first, as derived. */ export function applySubagentToolCallView( calls: ReadonlyArray, view: SubagentToolCallView, ): ReadonlyArray { - const query = view.query.trim().toLocaleLowerCase(); - const visible = calls.filter( - (call) => - (view.statuses.length === 0 || view.statuses.includes(call.status)) && - (view.kinds.length === 0 || view.kinds.includes(call.kind)) && - (query.length === 0 || subagentToolCallText(call).toLocaleLowerCase().includes(query)), + const visible = applyFilterDimensions( + searchSubagentToolCalls(calls, view.query), + toolCallDimensions(view), ); switch (view.sort) { case "oldest":