diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index eed8c174f..e4ce57a06 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.12.0" + ".": "1.13.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 58987c36c..905c68040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.13.0](https://github.com/trycompai/crm/compare/v1.12.0...v1.13.0) (2026-08-12) + + +### Features + +* **app:** search company dropdowns instead of scrolling them ([#125](https://github.com/trycompai/crm/issues/125)) ([3b558a8](https://github.com/trycompai/crm/commit/3b558a82155d556aa4ff2860121ddf314e2ae88c)) + + +### Fixes + +* **agent:** let the assistant chat read the deal list it is told to use (CMP-77) ([#139](https://github.com/trycompai/crm/issues/139)) ([e86a0fb](https://github.com/trycompai/crm/commit/e86a0fbe4076ec3ead43f9ab24c3ac805070819a)) +* **app:** show select field values in record tables ([#133](https://github.com/trycompai/crm/issues/133)) ([1d89b43](https://github.com/trycompai/crm/commit/1d89b43e2d0ad376970be09f9446f1903203ec09)) + ## [1.12.0](https://github.com/trycompai/crm/compare/v1.11.0...v1.12.0) (2026-08-11) diff --git a/apps/agent/agent/tools/list_deals.ts b/apps/agent/agent/tools/list_deals.ts index 5f8bb3f86..1c0644f04 100644 --- a/apps/agent/agent/tools/list_deals.ts +++ b/apps/agent/agent/tools/list_deals.ts @@ -1,7 +1,6 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; import { listDeals } from "../lib/lookup"; -import { assertResearchPurpose } from "../lib/session-purpose"; export default defineTool({ description: @@ -22,8 +21,7 @@ export default defineTool({ limit: z.number().int().min(1).max(100).default(50), cursor: z.string().optional(), }), - async execute(input, ctx) { - assertResearchPurpose(ctx); + async execute(input) { return listDeals(input); }, toModelOutput(output) { diff --git a/apps/agent/test/custom-agent-runtime.spec.ts b/apps/agent/test/custom-agent-runtime.spec.ts index 4ae561681..330d2a581 100644 --- a/apps/agent/test/custom-agent-runtime.spec.ts +++ b/apps/agent/test/custom-agent-runtime.spec.ts @@ -158,6 +158,15 @@ describe("session purpose boundaries", () => { expect(() => assertResearchPurpose(context("team-agent"))).toThrow(); }); + it("leaves the read-only deal list open to the assistant chat that is told to use it", async () => { + const source = await Bun.file( + new URL("../agent/tools/list_deals.ts", import.meta.url), + ).text(); + + expect(source).not.toContain("assertResearchPurpose"); + expect(builderTaskMarkdown(null)).toContain("list_deals"); + }); + it("binds specialist tools to their explicit session purpose", () => { expect( requireBuilderAttribute( diff --git a/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx b/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx index c992755b9..50139c4df 100644 --- a/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx +++ b/apps/app/app/(app)/[slug]/contacts/contacts-bulk-actions.tsx @@ -5,7 +5,6 @@ import TrashCan from "@carbon/icons-react/es/TrashCan"; import { DropdownMenuGroup, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, @@ -13,7 +12,7 @@ import { } from "@crm/ui/components/dropdown-menu"; import { formatCount } from "@crm/ui/lib/format"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { toast } from "sonner"; import { BulkActionsMenu, @@ -21,6 +20,7 @@ import { BulkOwnerMenu, reportBulk, } from "@/components/crm/bulk-actions"; +import { CompanyMenuSearch } from "@/components/crm/company-picker"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; @@ -38,8 +38,9 @@ export function ContactsBulkActions({ const trpc = useTRPC(); const cache = useCrmCache(); const users = useQuery(trpc.users.list.queryOptions()); - const companies = useQuery(trpc.companies.options.queryOptions({ q: "" })); const [confirming, setConfirming] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const companySearch = useRef(null); const onError = (error: { message: string }) => toast.error(error.message); @@ -99,7 +100,11 @@ export function ContactsBulkActions({ return ( <> - + Move to company - - - setCompany.mutate({ ids, companyId: null })} - > - No company - - {companies.data?.length === 0 ? ( - No companies yet. - ) : ( - companies.data?.map((company) => ( - - setCompany.mutate({ ids, companyId: company.id }) - } - > - {company.name} - - )) - )} - + { + if (event.target === event.currentTarget) { + companySearch.current?.focus(); + } + }} + > + { + setMenuOpen(false); + setCompany.mutate({ ids, companyId }); + }} + /> diff --git a/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx b/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx index 313a273d5..8b7ccb90e 100644 --- a/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx +++ b/apps/app/app/(app)/[slug]/contacts/contacts-table.tsx @@ -7,9 +7,10 @@ import { } from "@crm/ui/components/data-table"; import { EmptyCellValue } from "@crm/ui/components/empty-cell"; import { PersonAvatar } from "@crm/ui/components/person-avatar"; +import { useSearchInput } from "@crm/ui/hooks/use-search-input"; import { useTableSelection } from "@crm/ui/hooks/use-table-selection"; import { useQuery } from "@tanstack/react-query"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { CompanyCell } from "@/components/crm/company-cell"; import { contactName } from "@/components/crm/contact-name"; import { useFieldColumns } from "@/components/crm/fields/field-columns"; @@ -130,7 +131,16 @@ export function ContactsTable() { placeholderData: (previous) => previous, }); const users = useQuery(trpc.users.list.queryOptions()); - const companies = useQuery(trpc.companies.options.queryOptions({ q: "" })); + + const [companyQuery, setCompanyQuery] = useState(""); + const [companyText, setCompanyText] = useSearchInput( + companyQuery, + setCompanyQuery, + ); + const companies = useQuery({ + ...trpc.companies.options.queryOptions({ q: companyQuery }), + placeholderData: (previous) => previous, + }); const rows = contacts.data?.rows ?? []; const selection = useTableSelection( @@ -154,8 +164,15 @@ export function ContactsTable() { { id: "company", label: "Company", + searchable: true, + search: companyText, + onSearchChange: setCompanyText, + stale: companies.isFetching || companyText.trim() !== companyQuery.trim(), + empty: companies.isFetching ? "Searching…" : "No company matches.", options: [ - { value: "none", label: "No company" }, + ...(companyQuery.trim() + ? [] + : [{ value: "none", label: "No company" }]), ...(companies.data ?? []).map((company) => ({ value: company.id, label: company.name, diff --git a/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx b/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx index 795e35325..1494143e4 100644 --- a/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx +++ b/apps/app/app/(app)/[slug]/contacts/create-contact-sheet.tsx @@ -27,6 +27,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { parseAsBoolean, useQueryState } from "nuqs"; import { type ComponentProps, Suspense, useId, useState } from "react"; import { toast } from "sonner"; +import { CompanyPicker } from "@/components/crm/company-picker"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { useCrmCache } from "@/lib/trpc/cache"; import { useTRPC } from "@/lib/trpc/client"; @@ -72,7 +73,6 @@ function CreateContactForm({ companyId }: { companyId?: string }) { const titleId = useId(); const users = useQuery(trpc.users.list.queryOptions()); - const companies = useQuery(trpc.companies.options.queryOptions({ q: "" })); const create = useMutation( trpc.contacts.create.mutationOptions({ @@ -167,19 +167,12 @@ function CreateContactForm({ companyId }: { companyId?: string }) { Company - + diff --git a/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx b/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx index a0d5a91db..6d3e57b85 100644 --- a/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx +++ b/apps/app/app/(app)/[slug]/deals/create-deal-sheet.tsx @@ -34,6 +34,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { parseAsBoolean, useQueryState } from "nuqs"; import { type ComponentProps, Suspense, useId, useState } from "react"; import { toast } from "sonner"; +import { CompanyPicker } from "@/components/crm/company-picker"; import { useOpenRecord } from "@/components/crm/record-sheet/record-stack"; import { dealStageLabel, OPEN_STAGES } from "@/lib/deal-stage"; import { useCrmCache } from "@/lib/trpc/cache"; @@ -80,7 +81,6 @@ function CreateDealForm({ companyId }: { companyId?: string }) { const closeDateId = useId(); const users = useQuery(trpc.users.list.queryOptions()); - const companies = useQuery(trpc.companies.options.queryOptions({ q: "" })); const me = useQuery(trpc.users.me.queryOptions()); const currencies = useQuery(trpc.currency.settings.queryOptions()); @@ -154,18 +154,11 @@ function CreateDealForm({ companyId }: { companyId?: string }) { Company - + diff --git a/apps/app/components/crm/bulk-actions.tsx b/apps/app/components/crm/bulk-actions.tsx index 8aaa954b5..7c15c8999 100644 --- a/apps/app/components/crm/bulk-actions.tsx +++ b/apps/app/components/crm/bulk-actions.tsx @@ -57,13 +57,17 @@ export function reportBulk( export function BulkActionsMenu({ pending, + open, + onOpenChange, children, }: { pending?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; children: ReactNode; }) { return ( - + + + + + + + + {empty} + + {options.map((option) => ( + { + close(); + onValueChange(option.value); + }} + > + {option.label} + {option.hint ? ( + + {option.hint} + + ) : null} + + ))} + + + + + + ); +} + +export { Combobox, type ComboboxOption }; diff --git a/packages/ui/src/components/data-table.tsx b/packages/ui/src/components/data-table.tsx index 03b31b52f..dd7819ab1 100644 --- a/packages/ui/src/components/data-table.tsx +++ b/packages/ui/src/components/data-table.tsx @@ -9,6 +9,14 @@ import Column from "@carbon/icons-react/es/Column"; import Filter from "@carbon/icons-react/es/Filter"; import { Button } from "@crm/ui/components/button"; import { Checkbox } from "@crm/ui/components/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@crm/ui/components/command"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -19,6 +27,11 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@crm/ui/components/dropdown-menu"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@crm/ui/components/popover"; import { Spinner } from "@crm/ui/components/spinner"; import { TablePagination } from "@crm/ui/components/table-pagination"; import { @@ -35,6 +48,7 @@ import type { TableQueryState } from "@crm/ui/lib/table-query"; import { cn } from "@crm/ui/lib/utils"; import { parseAsArrayOf, parseAsString, useQueryState } from "nuqs"; import { + type ComponentProps, Fragment, type ReactNode, useDeferredValue, @@ -62,6 +76,11 @@ export type DataTableFacet = { id: string; label: string; options: { value: string; label: string }[]; + searchable?: boolean; + search?: string; + onSearchChange?: (search: string) => void; + stale?: boolean; + empty?: ReactNode; }; export type DataTableTabs = { @@ -146,6 +165,99 @@ function SortIndicator({ ); } +function FacetTrigger({ + label, + ...props +}: ComponentProps & { label: string }) { + return ( + + ); +} + +function SearchableFacet({ + facet, + selected, + activeLabel, + onSelect, +}: { + facet: DataTableFacet; + selected: string; + activeLabel?: string; + onSelect: (value: string) => void; +}) { + const [open, setOpen] = useState(false); + const [seen, setSeen] = useState<{ value: string; label: string } | null>( + null, + ); + + if (activeLabel && (seen?.value !== selected || seen.label !== activeLabel)) { + setSeen({ value: selected, label: activeLabel }); + } + + const label = + activeLabel ?? + (seen?.value === selected ? seen.label : undefined) ?? + facet.label; + + const choose = (value: string) => { + setOpen(false); + facet.onSearchChange?.(""); + onSelect(value); + }; + + return ( + { + setOpen(next); + if (!next) facet.onSearchChange?.(""); + }} + > + + + + + + + + {facet.empty ?? "Nothing matches."} + + {facet.search?.trim() ? null : ( + choose("all")} + > + {facet.label} + + )} + {facet.options.map((option) => ( + choose(option.value)} + > + {option.label} + + ))} + + + + + + ); +} + export function DataTable({ query, columns, @@ -330,22 +442,23 @@ export function DataTable({ {facets?.map((facet) => { const selected = query.filters[facet.id] ?? "all"; const active = facet.options.find((o) => o.value === selected); + + if (facet.searchable) { + return ( + query.setFilter(facet.id, value)} + /> + ); + } + return ( - +