feat(tickets): persistance des filtres tickets entre redémarrages (#29)

Introduit le port UiPreferencesGateway et son adapter uiPreferences,
avec le module ticketFilterPersistence qui sauvegarde/restaure les
filtres (recherche, statut, sprint, agents) via useTickets,
useTicketSearch et useProjectAgents. Couvert par tests unitaires et
un test d'intégration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 07:45:50 +02:00
parent 9ee7290cde
commit 79f06c26d9
13 changed files with 793 additions and 20 deletions

View File

@ -30,6 +30,7 @@ import { TauriWorkStateGateway } from "./workState";
import { TauriConversationGateway } from "./conversation";
import { TauriTicketGateway } from "./ticket";
import { TauriWindowGateway } from "./window";
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
function notImplemented(what: string): never {
const err: GatewayError = {
@ -67,6 +68,7 @@ export function createTauriGateways(): Gateways {
conversation: new TauriConversationGateway(),
ticket: new TauriTicketGateway(),
window: new TauriWindowGateway(),
uiPreferences: new LocalStorageUiPreferencesGateway(),
};
}
@ -89,4 +91,5 @@ export {
TauriConversationGateway,
TauriTicketGateway,
TauriWindowGateway,
LocalStorageUiPreferencesGateway,
};

View File

@ -88,6 +88,7 @@ import type {
TicketListQuery,
CreateTicketInput,
UpdateTicketInput,
UiPreferencesGateway,
ViewWindowClosed,
WindowGateway,
WorkStateGateway,
@ -2529,6 +2530,38 @@ export class MockTicketGateway implements TicketGateway {
}
}
/**
* In-memory {@link UiPreferencesGateway} for tests/dev (ticket #29). Mirrors the
* localStorage adapter's best-effort JSON round-trip without touching the DOM,
* so persisted-filter behaviour can be exercised offline.
*/
export class MockUiPreferencesGateway implements UiPreferencesGateway {
private readonly store = new Map<string, string>();
read(key: string): unknown {
const raw = this.store.get(key);
if (raw === undefined) return null;
try {
return JSON.parse(raw);
} catch {
this.store.delete(key);
return null;
}
}
write(key: string, value: unknown): void {
try {
this.store.set(key, JSON.stringify(value));
} catch {
/* best-effort */
}
}
remove(key: string): void {
this.store.delete(key);
}
}
function mostRestrictive(
project?: PermissionSet["fallback"],
agent?: PermissionSet["fallback"],
@ -2563,6 +2596,7 @@ export function createMockGateways(): Gateways {
conversation: new MockConversationGateway(),
ticket: new MockTicketGateway(systemGateway),
window: new MockWindowGateway(),
uiPreferences: new MockUiPreferencesGateway(),
};
}

View File

@ -31,6 +31,7 @@ describe("createMockGateways", () => {
"template",
"terminal",
"ticket",
"uiPreferences",
"window",
"workState",
]);

View File

@ -0,0 +1,65 @@
/**
* `localStorage`-backed {@link UiPreferencesGateway} (ticket #29).
*
* The only place that touches `window.localStorage`; features go through the
* port via DI. Everything is best-effort: a missing / private-mode / quota-full
* storage, or a corrupt JSON entry, degrades silently to "no preference" rather
* than throwing into the UI (persisted UI filters must never break the app).
*/
import type { UiPreferencesGateway } from "@/ports";
/** Returns the `Storage` if usable in this environment, else `null`. */
function storage(): Storage | null {
try {
return typeof window !== "undefined" ? window.localStorage : null;
} catch {
// Accessing `localStorage` can throw (sandboxed / disabled cookies).
return null;
}
}
export class LocalStorageUiPreferencesGateway implements UiPreferencesGateway {
read(key: string): unknown {
const store = storage();
if (!store) return null;
let raw: string | null;
try {
raw = store.getItem(key);
} catch {
return null;
}
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
// Corrupt entry — drop it so we stop tripping over it, return default.
try {
store.removeItem(key);
} catch {
/* ignore */
}
return null;
}
}
write(key: string, value: unknown): void {
const store = storage();
if (!store) return;
try {
store.setItem(key, JSON.stringify(value));
} catch {
/* quota / serialisation failure — persistence is best-effort */
}
}
remove(key: string): void {
const store = storage();
if (!store) return;
try {
store.removeItem(key);
} catch {
/* ignore */
}
}
}

View File

@ -31,6 +31,7 @@ import type { TicketRef } from "@/domain";
import type { TicketListQuery } from "@/ports";
import { Button, Spinner, cn, zIndex } from "@/shared";
import { TicketFacetsBar } from "./TicketFacetsBar";
import { pickerFiltersKey } from "./ticketFilterPersistence";
import { PriorityBadge, StatusBadge, TicketRef as TicketRefBadge } from "./ticketMeta";
import {
useTicketSearch,
@ -55,6 +56,13 @@ export interface TicketPickerProps {
excludeRefs?: TicketRef[];
/** Seed filter/search query. */
initialQuery?: TicketListQuery;
/**
* Whether the picker persists its filters across openings (ticket #29,
* default `true`). `initialQuery` still wins per field, so a caller imposing a
* business filter keeps priority; a caller that must never inherit stored
* preferences can opt out entirely with `false`.
*/
persistFilters?: boolean;
/**
* Selection callback. In `"single"` mode it receives one
* {@link TicketPickerResult}; in `"multi"` it receives the array.
@ -76,6 +84,7 @@ export function TicketPicker({
selectionMode = "single",
excludeRefs,
initialQuery,
persistFilters = true,
onSelect,
onClose,
}: TicketPickerProps) {
@ -88,6 +97,7 @@ export function TicketPicker({
selectionMode={selectionMode}
excludeRefs={excludeRefs}
initialQuery={initialQuery}
persistFilters={persistFilters}
onSelect={onSelect}
onClose={onClose}
/>
@ -106,16 +116,27 @@ function TicketPickerBody({
selectionMode,
excludeRefs,
initialQuery,
persistFilters,
onSelect,
onClose,
}: Required<
Pick<
TicketPickerProps,
"projectId" | "title" | "confirmLabel" | "selectionMode" | "onSelect" | "onClose"
| "projectId"
| "title"
| "confirmLabel"
| "selectionMode"
| "persistFilters"
| "onSelect"
| "onClose"
>
> &
Pick<TicketPickerProps, "excludeRefs" | "initialQuery">) {
const vm = useTicketSearch(projectId, { initialQuery, excludeRefs });
const vm = useTicketSearch(projectId, {
initialQuery,
excludeRefs,
persistenceKey: persistFilters ? pickerFiltersKey(projectId) : undefined,
});
const dialogRef = useRef<HTMLDivElement>(null);
const [selecting, setSelecting] = useState(false);
const [resolveError, setResolveError] = useState<string | null>(null);

View File

@ -7,7 +7,7 @@
* through {@link useTickets}.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import type { Sprint, TicketPriority, TicketSummary } from "@/domain";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
@ -38,7 +38,21 @@ export interface TicketsPanelProps {
export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
const vm = useTickets(projectId);
const { agents, nameOf } = useProjectAgents(projectId);
const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId);
// Reconcile a *restored* assignee filter (ticket #29) against the live roster:
// once the agents are known, an assignee that no longer exists is dropped
// (which also clears it from persisted storage via `useTickets`). Guarded on
// `agentsLoaded` so a still-valid assignee is never cleared during the load
// window when the roster is momentarily empty.
useEffect(() => {
if (!agentsLoaded) return;
const id = vm.query.assignedAgentId;
if (id && !agents.some((a) => a.id === id)) {
const { assignedAgentId: _drop, ...rest } = vm.query;
vm.setQuery(rest);
}
}, [agentsLoaded, agents, vm.query, vm.setQuery]);
const [showCreate, setShowCreate] = useState(false);
const [showSprints, setShowSprints] = useState(false);
const [newTitle, setNewTitle] = useState("");

View File

@ -0,0 +1,116 @@
import { describe, it, expect } from "vitest";
import type { TicketListQuery } from "@/ports";
import {
listFiltersKey,
parseTicketFilters,
pickerFiltersKey,
sanitizeListFilters,
sanitizePickerFilters,
} from "./ticketFilterPersistence";
describe("ticketFilterPersistence keys", () => {
it("scopes keys by project and surface (list vs picker disjoint)", () => {
expect(listFiltersKey("p1")).toBe("tickets:listFilters:p1");
expect(pickerFiltersKey("p1")).toBe("tickets:pickerFilters:p1");
expect(listFiltersKey("p1")).not.toBe(pickerFiltersKey("p1"));
});
});
describe("parseTicketFilters (validation)", () => {
it("returns an empty query for non-objects / corrupt input", () => {
expect(parseTicketFilters(null)).toEqual({});
expect(parseTicketFilters(undefined)).toEqual({});
expect(parseTicketFilters("nope")).toEqual({});
expect(parseTicketFilters(42)).toEqual({});
expect(parseTicketFilters([1, 2, 3])).toEqual({});
});
it("keeps only known statuses/priorities and drops unknown values", () => {
const parsed = parseTicketFilters({
statuses: ["open", "bogus", "QA", "open"],
priorities: ["high", "nope"],
});
expect(parsed.statuses).toEqual(["open", "QA"]); // deduped, unknown dropped
expect(parsed.priorities).toEqual(["high"]);
});
it("keeps valid text/sort/limit and rejects malformed ones", () => {
expect(parseTicketFilters({ text: "boom" }).text).toBe("boom");
expect(parseTicketFilters({ text: "" }).text).toBeUndefined();
expect(
parseTicketFilters({ sort: { field: "number", direction: "desc" } }).sort,
).toEqual({ field: "number", direction: "desc" });
expect(
parseTicketFilters({ sort: { field: "bogus", direction: "desc" } }).sort,
).toBeUndefined();
expect(parseTicketFilters({ limit: 50 }).limit).toBe(50);
expect(parseTicketFilters({ limit: -1 }).limit).toBeUndefined();
expect(parseTicketFilters({ limit: 1.5 }).limit).toBeUndefined();
});
it("never restores the opaque cursor", () => {
const parsed = parseTicketFilters({ text: "x", cursor: "opaque-token" });
expect(parsed.text).toBe("x");
expect("cursor" in parsed).toBe(false);
});
it("drops an obsolete assignee when it is not among the known agents", () => {
const raw = { assignedAgentId: "gone" };
// No roster provided → shape-only (assignee kept).
expect(parseTicketFilters(raw).assignedAgentId).toBe("gone");
// Roster provided and missing the id → assignee dropped.
expect(
parseTicketFilters(raw, { validAgentIds: new Set(["a1"]) }).assignedAgentId,
).toBeUndefined();
// Roster provided and containing the id → kept.
expect(
parseTicketFilters(raw, { validAgentIds: new Set(["gone"]) })
.assignedAgentId,
).toBe("gone");
});
});
describe("sanitize (persistable subsets)", () => {
const full: TicketListQuery = {
text: "hi",
statuses: ["open"],
priorities: ["high"],
assignedAgentId: "a1",
sort: { field: "title", direction: "asc" },
limit: 100,
cursor: "opaque",
};
it("list keeps assignee + limit but never cursor", () => {
const out = sanitizeListFilters(full)!;
expect(out).toEqual({
text: "hi",
statuses: ["open"],
priorities: ["high"],
assignedAgentId: "a1",
sort: { field: "title", direction: "asc" },
limit: 100,
});
expect("cursor" in out).toBe(false);
});
it("picker drops assignee, limit AND cursor", () => {
const out = sanitizePickerFilters(full)!;
expect(out).toEqual({
text: "hi",
statuses: ["open"],
priorities: ["high"],
sort: { field: "title", direction: "asc" },
});
expect("assignedAgentId" in out).toBe(false);
expect("limit" in out).toBe(false);
expect("cursor" in out).toBe(false);
});
it("returns null when nothing is worth persisting", () => {
expect(sanitizeListFilters({})).toBeNull();
expect(sanitizeListFilters({ cursor: "only-a-cursor" })).toBeNull();
expect(sanitizePickerFilters({ assignedAgentId: "a1" })).toBeNull();
});
});

View File

@ -0,0 +1,231 @@
/**
* Persistence of ticket filters across restarts / reopens (ticket #29).
*
* Frontend-only: filters are UI preferences, so they live behind the thin
* {@link UiPreferencesGateway} (localStorage adapter) — the backend stays the
* source of tickets, never of these preferences. Persistence is scoped **per
* project and per surface** through two disjoint keys so the main list and the
* {@link TicketPicker} popup never pollute each other:
*
* - `tickets:listFilters:<projectId>` — the main tickets window.
* - `tickets:pickerFilters:<projectId>` — the reusable ticket picker.
*
* We only ever store the *stable* criteria (`text`, `statuses`, `priorities`,
* `sort`, `limit`, and — main list only — `assignedAgentId`). The opaque
* `cursor` is **never** persisted: it is a transient pagination token whose reuse
* across sessions is meaningless (G3-bis). Reads are defensive — unknown values
* are dropped and a corrupt blob yields the default empty query.
*/
import type { TicketPriority, TicketStatus } from "@/domain";
import type {
TicketListQuery,
TicketListSort,
TicketListSortDirection,
TicketListSortField,
UiPreferencesGateway,
} from "@/ports";
const VALID_STATUSES: readonly TicketStatus[] = [
"open",
"inProgress",
"QA",
"closed",
];
const VALID_PRIORITIES: readonly TicketPriority[] = [
"low",
"medium",
"high",
"critical",
];
const VALID_SORT_FIELDS: readonly TicketListSortField[] = [
"number",
"priority",
"status",
"title",
];
const VALID_SORT_DIRECTIONS: readonly TicketListSortDirection[] = ["asc", "desc"];
/** Storage key for the main tickets window, scoped to `projectId`. */
export function listFiltersKey(projectId: string): string {
return `tickets:listFilters:${projectId}`;
}
/** Storage key for the {@link TicketPicker} popup, scoped to `projectId`. */
export function pickerFiltersKey(projectId: string): string {
return `tickets:pickerFilters:${projectId}`;
}
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/** Keeps only the members of `allowed`, de-duplicated, order preserved. */
function filterEnumArray<T extends string>(
raw: unknown,
allowed: readonly T[],
): T[] | undefined {
if (!Array.isArray(raw)) return undefined;
const set = new Set(allowed);
const out: T[] = [];
for (const v of raw) {
if (typeof v === "string" && set.has(v as T) && !out.includes(v as T)) {
out.push(v as T);
}
}
return out.length > 0 ? out : undefined;
}
function parseSort(raw: unknown): TicketListSort | undefined {
if (!isObject(raw)) return undefined;
const { field, direction } = raw;
if (
typeof field === "string" &&
(VALID_SORT_FIELDS as readonly string[]).includes(field) &&
typeof direction === "string" &&
(VALID_SORT_DIRECTIONS as readonly string[]).includes(direction)
) {
return {
field: field as TicketListSortField,
direction: direction as TicketListSortDirection,
};
}
return undefined;
}
/** Options for {@link parseTicketFilters}. */
export interface ParseTicketFiltersOptions {
/**
* When provided, a persisted `assignedAgentId` is kept only if it belongs to
* this set — an obsolete assignee (agent since deleted) is dropped. When
* `undefined`, the assignee is validated for *shape* only (the caller does not
* know the agent roster yet and reconciles later).
*/
validAgentIds?: ReadonlySet<string>;
}
/**
* Validates an untrusted persisted blob into a clean {@link TicketListQuery}.
* Unknown fields/values are ignored; a non-object (corrupt JSON already yielded
* `null` upstream) yields the default empty query. `cursor` is never read back.
*/
export function parseTicketFilters(
raw: unknown,
options: ParseTicketFiltersOptions = {},
): TicketListQuery {
if (!isObject(raw)) return {};
const query: TicketListQuery = {};
if (typeof raw.text === "string" && raw.text.length > 0) {
query.text = raw.text;
}
const statuses = filterEnumArray(raw.statuses, VALID_STATUSES);
if (statuses) query.statuses = statuses;
const priorities = filterEnumArray(raw.priorities, VALID_PRIORITIES);
if (priorities) query.priorities = priorities;
if (typeof raw.assignedAgentId === "string" && raw.assignedAgentId) {
const { validAgentIds } = options;
if (!validAgentIds || validAgentIds.has(raw.assignedAgentId)) {
query.assignedAgentId = raw.assignedAgentId;
}
}
const sort = parseSort(raw.sort);
if (sort) query.sort = sort;
if (
typeof raw.limit === "number" &&
Number.isInteger(raw.limit) &&
raw.limit > 0
) {
query.limit = raw.limit;
}
// `cursor` is intentionally never restored.
return query;
}
/**
* Reduces a live query to the persistable subset for the **main list**: keeps
* `text/statuses/priorities/assignedAgentId/sort/limit`, drops `cursor`. Returns
* `null` when nothing is worth storing (so the caller can remove the key).
*/
export function sanitizeListFilters(query: TicketListQuery): TicketListQuery | null {
const out: TicketListQuery = {};
if (query.text) out.text = query.text;
if (query.statuses && query.statuses.length > 0) out.statuses = query.statuses;
if (query.priorities && query.priorities.length > 0) {
out.priorities = query.priorities;
}
if (query.assignedAgentId) out.assignedAgentId = query.assignedAgentId;
if (query.sort) out.sort = query.sort;
if (typeof query.limit === "number") out.limit = query.limit;
return Object.keys(out).length > 0 ? out : null;
}
/**
* Reduces a live query to the persistable subset for the **picker**: like the
* list but *without* `assignedAgentId` (assignee is a main-list-only preference)
* and without `limit` (the picker's limit is a caller/business concern, not a
* user preference). `cursor` is likewise dropped.
*/
export function sanitizePickerFilters(
query: TicketListQuery,
): TicketListQuery | null {
const out: TicketListQuery = {};
if (query.text) out.text = query.text;
if (query.statuses && query.statuses.length > 0) out.statuses = query.statuses;
if (query.priorities && query.priorities.length > 0) {
out.priorities = query.priorities;
}
if (query.sort) out.sort = query.sort;
return Object.keys(out).length > 0 ? out : null;
}
/** Reads + validates the persisted main-list filters (empty query on miss). */
export function hydrateListFilters(
prefs: UiPreferencesGateway | undefined,
projectId: string,
options?: ParseTicketFiltersOptions,
): TicketListQuery {
if (!prefs) return {};
return parseTicketFilters(prefs.read(listFiltersKey(projectId)), options);
}
/** Persists (or clears) the main-list filters for a project. */
export function persistListFilters(
prefs: UiPreferencesGateway | undefined,
projectId: string,
query: TicketListQuery,
): void {
if (!prefs) return;
const sanitized = sanitizeListFilters(query);
const key = listFiltersKey(projectId);
if (sanitized) prefs.write(key, sanitized);
else prefs.remove(key);
}
/** Reads + validates the persisted picker filters (empty query on miss). */
export function hydratePickerFilters(
prefs: UiPreferencesGateway | undefined,
projectId: string,
): TicketListQuery {
if (!prefs) return {};
return parseTicketFilters(prefs.read(pickerFiltersKey(projectId)));
}
/** Persists (or clears) the picker filters for a project. */
export function persistPickerFilters(
prefs: UiPreferencesGateway | undefined,
projectId: string,
query: TicketListQuery,
): void {
if (!prefs) return;
const sanitized = sanitizePickerFilters(query);
const key = pickerFiltersKey(projectId);
if (sanitized) prefs.write(key, sanitized);
else prefs.remove(key);
}

View File

@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import {
MockAgentGateway,
MockSystemGateway,
MockTicketGateway,
MockUiPreferencesGateway,
} from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { TicketsPanel } from "./TicketsPanel";
import { TicketPicker } from "./TicketPicker";
import { listFiltersKey, pickerFiltersKey } from "./ticketFilterPersistence";
const PROJECT_ID = "project-persist-test";
describe("ticket #29 — persisted list filters (useTickets)", () => {
let system: MockSystemGateway;
let ticket: MockTicketGateway;
let agent: MockAgentGateway;
let prefs: MockUiPreferencesGateway;
beforeEach(() => {
system = new MockSystemGateway();
ticket = new MockTicketGateway(system);
agent = new MockAgentGateway();
prefs = new MockUiPreferencesGateway();
});
function renderPanel() {
const gateways = { system, ticket, agent, uiPreferences: prefs } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<TicketsPanel projectId={PROJECT_ID} onOpen={vi.fn()} />
</DIProvider>,
);
}
it("restores persisted list filters at mount", async () => {
prefs.write(listFiltersKey(PROJECT_ID), {
text: "restored-text",
statuses: ["open"],
});
renderPanel();
const search = (await screen.findByLabelText(
"search tickets",
)) as HTMLInputElement;
expect(search.value).toBe("restored-text");
});
it("persists a filter change (scoped to the list key)", async () => {
renderPanel();
const search = (await screen.findByLabelText(
"search tickets",
)) as HTMLInputElement;
fireEvent.change(search, { target: { value: "boom" } });
await waitFor(() => {
expect(prefs.read(listFiltersKey(PROJECT_ID))).toMatchObject({
text: "boom",
});
});
// The picker key stays untouched (surfaces do not pollute each other).
expect(prefs.read(pickerFiltersKey(PROJECT_ID))).toBeNull();
});
it("drops an obsolete persisted assignee once the roster is known", async () => {
prefs.write(listFiltersKey(PROJECT_ID), {
text: "keep-me",
assignedAgentId: "ghost-agent",
});
renderPanel();
// The roster (empty here) has no such agent → the assignee is reconciled out
// and re-persisted without it, while the rest of the filters survive.
await waitFor(() => {
const stored = prefs.read(listFiltersKey(PROJECT_ID)) as
| Record<string, unknown>
| null;
expect(stored).not.toBeNull();
expect(stored).toMatchObject({ text: "keep-me" });
expect("assignedAgentId" in (stored ?? {})).toBe(false);
});
});
});
describe("ticket #29 — persisted picker filters (TicketPicker)", () => {
let system: MockSystemGateway;
let ticket: MockTicketGateway;
let prefs: MockUiPreferencesGateway;
beforeEach(() => {
system = new MockSystemGateway();
ticket = new MockTicketGateway(system);
prefs = new MockUiPreferencesGateway();
});
function renderPicker(
props: Partial<React.ComponentProps<typeof TicketPicker>> = {},
) {
const gateways = { system, ticket, uiPreferences: prefs } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<TicketPicker
open
projectId={PROJECT_ID}
onSelect={vi.fn()}
onClose={vi.fn()}
{...props}
/>
</DIProvider>,
);
}
it("restores persisted picker filters on reopen", async () => {
prefs.write(pickerFiltersKey(PROJECT_ID), { text: "picker-pref" });
renderPicker();
const search = (await screen.findByLabelText(
"search tickets",
)) as HTMLInputElement;
expect(search.value).toBe("picker-pref");
});
it("lets initialQuery win over the persisted preference for imposed fields", async () => {
await ticket.create(PROJECT_ID, { title: "Open item", status: "open" });
const closed = await ticket.create(PROJECT_ID, { title: "Closed item" });
await ticket.update(PROJECT_ID, closed.ref, {
status: "closed",
expectedVersion: closed.version,
});
// The stored preference asks for "closed", but the caller imposes "open".
prefs.write(pickerFiltersKey(PROJECT_ID), { statuses: ["closed"] });
renderPicker({ initialQuery: { statuses: ["open"] } });
// initialQuery wins → the open item shows, the closed one is filtered out.
expect(await screen.findByText("Open item")).toBeTruthy();
expect(screen.queryByText("Closed item")).toBeNull();
});
it("never persists the opaque cursor, even after paginating", async () => {
for (let i = 0; i < 3; i += 1) {
await ticket.create(PROJECT_ID, { title: `Alpha ${i}` });
}
// Seed text so something is persisted; small limit forces a nextCursor.
renderPicker({ initialQuery: { text: "Alpha", limit: 2 } });
// Wait for the first page and its "Load more" (nextCursor present).
const loadMore = await screen.findByRole("button", { name: /load more/i });
fireEvent.click(loadMore);
await screen.findByText("Alpha 0");
await waitFor(() => {
const stored = prefs.read(pickerFiltersKey(PROJECT_ID)) as
| Record<string, unknown>
| null;
expect(stored).toMatchObject({ text: "Alpha" });
});
const stored = prefs.read(pickerFiltersKey(PROJECT_ID)) as Record<
string,
unknown
>;
expect("cursor" in stored).toBe(false);
expect("limit" in stored).toBe(false);
});
});

View File

@ -14,21 +14,35 @@ export interface ProjectAgents {
agents: Agent[];
/** Maps an agent id to a display name, falling back to a short id. */
nameOf: (agentId: string) => string;
/**
* `true` once the agent roster has been fetched for the current project.
* Callers that reconcile persisted state against known agents (ticket #29)
* must wait for this so a valid assignee is not dropped during the load window.
*/
loaded: boolean;
}
export function useProjectAgents(projectId: string): ProjectAgents {
const { agent } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
setLoaded(false);
void agent
.listAgents(projectId)
.then((list) => {
if (!cancelled) setAgents(list);
if (!cancelled) {
setAgents(list);
setLoaded(true);
}
})
.catch(() => {
if (!cancelled) setAgents([]);
if (!cancelled) {
setAgents([]);
setLoaded(true);
}
});
return () => {
cancelled = true;
@ -43,5 +57,5 @@ export function useProjectAgents(projectId: string): ProjectAgents {
const nameOf = (agentId: string): string =>
byId.get(agentId) ?? `${agentId.slice(0, 8)}`;
return { agents, nameOf };
return { agents, nameOf, loaded };
}

View File

@ -20,6 +20,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
parseTicketFilters,
sanitizePickerFilters,
} from "./ticketFilterPersistence";
import type {
GatewayError,
TicketPriority,
@ -62,6 +67,16 @@ export interface UseTicketSearchOptions {
* (#37) enables it so its independent query stays in sync with add/remove.
*/
refreshOnEvents?: boolean;
/**
* Storage key under which this surface persists its filters (ticket #29). When
* set, the facets/text/sort are hydrated from it at mount and re-persisted on
* change — but **`initialQuery` wins per field**: any criterion the caller
* imposes (a business filter such as a sprint context) overrides the persisted
* preference for that field. `assignedAgentId`, `limit` and the opaque `cursor`
* are never persisted here (picker surface). When omitted, no persistence
* happens (default) so unrelated `useTicketSearch` call-sites are unaffected.
*/
persistenceKey?: string;
}
export interface TicketSearchViewModel {
@ -110,20 +125,48 @@ export function useTicketSearch(
projectId: string,
options: UseTicketSearchOptions = {},
): TicketSearchViewModel {
const { initialQuery, excludeRefs, debounceMs = 200, refreshOnEvents } =
options;
const { ticket, system } = useGateways();
const {
initialQuery,
excludeRefs,
debounceMs = 200,
refreshOnEvents,
persistenceKey,
} = options;
const { ticket, system, uiPreferences } = useGateways();
// Seed the initial facets/text/sort once (ticket #29): the persisted
// preference is the base, but `initialQuery` overrides it per field so a
// caller-imposed business filter always wins over a stored one.
const seedRef = useRef<{
statuses?: TicketStatus[];
priorities?: TicketPriority[];
assignedAgentId?: string;
text?: string;
sort?: TicketListSort;
} | null>(null);
if (seedRef.current === null) {
const persisted =
persistenceKey && uiPreferences
? parseTicketFilters(uiPreferences.read(persistenceKey))
: {};
seedRef.current = {
statuses: initialQuery?.statuses ?? persisted.statuses,
priorities: initialQuery?.priorities ?? persisted.priorities,
assignedAgentId: initialQuery?.assignedAgentId ?? persisted.assignedAgentId,
text: initialQuery?.text ?? persisted.text,
sort: initialQuery?.sort ?? persisted.sort,
};
}
const seed = seedRef.current;
const [facets, setFacets] = useState<Facets>({
statuses: initialQuery?.statuses,
priorities: initialQuery?.priorities,
assignedAgentId: initialQuery?.assignedAgentId,
statuses: seed.statuses,
priorities: seed.priorities,
assignedAgentId: seed.assignedAgentId,
});
const [text, setText] = useState(initialQuery?.text ?? "");
const [text, setText] = useState(seed.text ?? "");
const [debouncedText, setDebouncedText] = useState(text);
const [sort, setSort] = useState<TicketListSort | undefined>(
initialQuery?.sort,
);
const [sort, setSort] = useState<TicketListSort | undefined>(seed.sort);
const [items, setItems] = useState<TicketSummary[]>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
@ -149,6 +192,26 @@ export function useTicketSearch(
return () => clearTimeout(id);
}, [text, debounceMs]);
// Persist the picker's filters (ticket #29): text/statuses/priorities/sort
// only — never `assignedAgentId`, `limit` or the opaque `cursor`. Keyed by the
// caller-provided `persistenceKey`; a no-op when persistence is disabled.
useEffect(() => {
if (!persistenceKey || !uiPreferences) return;
const trimmed = debouncedText.trim();
const sanitized = sanitizePickerFilters({
...(facets.statuses && facets.statuses.length > 0
? { statuses: facets.statuses }
: {}),
...(facets.priorities && facets.priorities.length > 0
? { priorities: facets.priorities }
: {}),
...(trimmed ? { text: trimmed } : {}),
...(sort ? { sort } : {}),
});
if (sanitized) uiPreferences.write(persistenceKey, sanitized);
else uiPreferences.remove(persistenceKey);
}, [persistenceKey, uiPreferences, facets, debouncedText, sort]);
// A key that changes whenever the effective query (minus cursor) changes, so
// the first-page effect refetches exactly when a criterion moves.
const queryKey = useMemo(

View File

@ -11,7 +11,7 @@
* or out of a sprint. Sprint lifecycle events refresh the sprint list.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
isSprintEvent,
@ -25,6 +25,10 @@ import {
} from "@/domain";
import type { CreateTicketInput, TicketListQuery } from "@/ports";
import { useGateways } from "@/app/di";
import {
hydrateListFilters,
persistListFilters,
} from "./ticketFilterPersistence";
export interface TicketsViewModel {
list: TicketList | null;
@ -76,12 +80,33 @@ function describe(e: unknown): string {
}
export function useTickets(projectId: string): TicketsViewModel {
const { ticket, system } = useGateways();
const { ticket, system, uiPreferences } = useGateways();
const [list, setList] = useState<TicketList | null>(null);
const [sprints, setSprints] = useState<Sprint[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [query, setQuery] = useState<TicketListQuery>({});
// Hydrate the filters persisted for this project (ticket #29) on first render
// so reopening the window restores them without a flash of defaults. The
// assignee, if any, is validated for shape here and reconciled against the
// live agent roster by the panel (an obsolete assignee is then cleared).
const [query, setQuery] = useState<TicketListQuery>(() =>
hydrateListFilters(uiPreferences, projectId),
);
// Re-hydrate when the hosting project changes (the mounted hook is normally
// pinned to one project, but stay correct if `projectId` is swapped in place).
const hydratedProject = useRef(projectId);
useEffect(() => {
if (hydratedProject.current === projectId) return;
hydratedProject.current = projectId;
setQuery(hydrateListFilters(uiPreferences, projectId));
}, [projectId, uiPreferences]);
// Persist the filters (minus the opaque cursor) whenever they change, scoped
// to this project's list key.
useEffect(() => {
persistListFilters(uiPreferences, projectId, query);
}, [uiPreferences, projectId, query]);
// Stable dependency key so `refresh` only changes when a filter actually does.
const queryKey = useMemo(() => JSON.stringify(query), [query]);

View File

@ -971,6 +971,29 @@ export interface WindowGateway {
): Promise<Unsubscribe>;
}
/**
* Thin UI-preferences port (ticket #29). Persists small, per-surface pieces of
* UI state (like the ticket filters) so they survive a restart / reopen. This is
* **frontend-owned** state: the backend stays the source of tickets, never of UI
* preferences. Values are opaque JSON blobs addressed by an explicit string key;
* the adapter serialises/parses them. Reads are best-effort — an absent or
* corrupt entry yields `null` so callers fall back to their default state, and
* writes never throw (a full/blocked store degrades to "not persisted").
*
* Deliberately synchronous: hydration happens during a component's first render,
* so the persisted state is applied without a flash of default filters. The
* localStorage adapter is synchronous; a future async backend (Tauri store)
* would front this with an in-memory cache to keep the contract.
*/
export interface UiPreferencesGateway {
/** Reads and JSON-parses the blob at `key`; `null` when absent or invalid. */
read(key: string): unknown;
/** JSON-serialises and persists `value` at `key`. Best-effort (never throws). */
write(key: string, value: unknown): void;
/** Removes any persisted blob at `key`. Best-effort (never throws). */
remove(key: string): void;
}
/**
* The full set of gateways the app depends on, injected via the DI provider.
* The composition (real vs mock) is chosen in `app/`.
@ -995,4 +1018,5 @@ export interface Gateways {
conversation: ConversationGateway;
ticket: TicketGateway;
window: WindowGateway;
uiPreferences: UiPreferencesGateway;
}