Skip to content
Closed
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
317 changes: 202 additions & 115 deletions apps/web/src/components/chat/AgentDetailPanel.tsx

Large diffs are not rendered by default.

183 changes: 111 additions & 72 deletions apps/web/src/components/chat/AgentListToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,12 +36,21 @@ const SORT_LABELS: Record<AgentListSort, string> = {
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<AgentStatusFilter, number>;
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,
Expand All @@ -47,78 +60,104 @@ export function AgentListToolbar(props: {
});

return (
<div className="flex items-center gap-1 pb-1">
<Input
size="compact"
type="search"
value={view.query}
placeholder="Search agents"
aria-label="Search agents"
onChange={(event) => props.onChange({ ...view, query: event.target.value })}
className="min-w-0 flex-1"
/>
<Menu>
<MenuTrigger
render={
<Button
type="button"
size="xs"
variant={view.statuses.length > 0 ? "outline" : "ghost"}
aria-label="Filter agents by status"
/>
}
>
<ListFilterIcon />
{view.statuses.length > 0 ? view.statuses.length : null}
</MenuTrigger>
<MenuPopup align="end">
{(Object.keys(STATUS_LABELS) as AgentStatusFilter[]).map((status) => (
<MenuCheckboxItem
key={status}
checked={view.statuses.includes(status)}
onCheckedChange={(checked) => toggleStatus(status, checked)}
>
{STATUS_LABELS[status]}
</MenuCheckboxItem>
))}
</MenuPopup>
</Menu>
<Menu>
<MenuTrigger
render={
<Button
type="button"
size="xs"
variant="ghost"
aria-label={`Sort agents: ${SORT_LABELS[view.sort]}`}
/>
}
>
<ArrowDownUpIcon />
</MenuTrigger>
<MenuPopup align="end">
<MenuRadioGroup
value={view.sort}
onValueChange={(sort: AgentListSort) => props.onChange({ ...view, sort })}
<div className="flex flex-col gap-1.5 pb-1">
<div className="flex items-center gap-1">
<Input
size="compact"
type="search"
value={view.query}
placeholder="Search agents"
aria-label="Search agents"
onChange={(event) => props.onChange({ ...view, query: event.target.value })}
className="min-w-0 flex-1"
/>
<Menu open={statusMenuOpen} onOpenChange={setStatusMenuOpen}>
<MenuTrigger
render={
<Button
type="button"
size="xs"
variant={view.statuses.length > 0 ? "outline" : "ghost"}
aria-label="Filter agents by status"
/>
}
>
{(Object.keys(SORT_LABELS) as AgentListSort[]).map((sort) => (
<MenuRadioItem key={sort} value={sort}>
{SORT_LABELS[sort]}
</MenuRadioItem>
<ListFilterIcon />
</MenuTrigger>
<MenuPopup align="end">
{statuses.length === 0 ? <MenuItem disabled>No agents match</MenuItem> : null}
{statuses.map((status) => (
<MenuCheckboxItem
key={status}
checked={view.statuses.includes(status)}
onCheckedChange={(checked) => toggleStatus(status, checked)}
>
<span className="flex w-full min-w-0 items-center gap-2">
<MenuChoiceLabel
label={STATUS_LABELS[status]}
onOnly={() => {
// "Only" settles the filter, so it also closes the menu.
props.onChange({ ...view, statuses: [status] });
setStatusMenuOpen(false);
}}
/>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{props.statusCounts.get(status)}
</span>
</span>
</MenuCheckboxItem>
))}
</MenuRadioGroup>
</MenuPopup>
</Menu>
{view.statuses.length > 0 || view.query.length > 0 || view.sort !== "spawn" ? (
<Button
type="button"
size="icon-xs"
variant="ghost-muted"
aria-label="Reset agent filters"
onClick={() => props.onChange(DEFAULT_AGENT_LIST_VIEW)}
>
<XIcon />
</Button>
</MenuPopup>
</Menu>
<Menu>
<MenuTrigger
render={
<Button
type="button"
size="xs"
variant="ghost"
aria-label={`Sort agents: ${SORT_LABELS[view.sort]}`}
/>
}
>
<ArrowDownUpIcon />
</MenuTrigger>
<MenuPopup align="end">
<MenuRadioGroup
value={view.sort}
onValueChange={(sort: AgentListSort) => props.onChange({ ...view, sort })}
>
{(Object.keys(SORT_LABELS) as AgentListSort[]).map((sort) => (
<MenuRadioItem key={sort} value={sort}>
{SORT_LABELS[sort]}
</MenuRadioItem>
))}
</MenuRadioGroup>
</MenuPopup>
</Menu>
{view.statuses.length > 0 || view.query.length > 0 || view.sort !== "spawn" ? (
<Button
type="button"
size="icon-xs"
variant="ghost-muted"
aria-label="Reset agent filters"
onClick={() => props.onChange(DEFAULT_AGENT_LIST_VIEW)}
>
<XIcon />
</Button>
) : null}
</div>
{view.statuses.length > 0 ? (
<div className="flex flex-wrap items-center gap-1">
{view.statuses.map((status) => (
<FilterPill
key={status}
icon={<CircleDotIcon />}
label={STATUS_LABELS[status]}
onRemove={() => toggleStatus(status, false)}
/>
))}
</div>
) : null}
</div>
);
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/chat/AgentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -277,7 +286,7 @@ function AgentFleetList(props: AgentsPanelProps) {
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center gap-1 px-2 pt-2">
<div className="min-w-0 flex-1">
<AgentListToolbar view={view} onChange={setView} />
<AgentListToolbar view={view} statusCounts={statusCounts} onChange={setView} />
</div>
<div className="flex items-center pb-1">
<RowDetailsToggle />
Expand Down
53 changes: 26 additions & 27 deletions apps/web/src/components/chat/ThreadAttachPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -34,6 +39,7 @@ import {
composerThreadReferenceItems,
DEFAULT_THREAD_ATTACH_PICKER_VIEW,
groupThreadAttachPickerItems,
threadAttachPickerFacets,
} from "./composerThreadReferences";
import { CursorPreviewCard } from "./CursorPreviewCard";
import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
Expand Down Expand Up @@ -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;
Expand Down
Loading