From 8a2d15e5cd9dae1d0346674a250143f65d77ca9b Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Thu, 20 Aug 2026 22:10:44 +0200 Subject: [PATCH 01/13] Add Taskboard filter state module Extracts workSourceSchema, workStateCategorySchema, and trackerViewSchema into a leaf work-schemas.ts module with no local imports of its own (re-exported from contract.ts and board-settings.ts unchanged for existing importers) and adds filter-state.ts, defining the persisted board filter shape, per-project scope-key collapsing, and normalization/fingerprinting so unordered array changes don't look like state changes. work-schemas.ts exists because filter-state.ts is the first module in this plugin that is both directly unit-tested and dependent on real (non-type-only) values from a sibling module. Every other tested module only crosses files via `import type`, which is erased before node --experimental-strip-types ever tries to resolve it; that loader never rewrites a .js specifier to a sibling .ts file, so a real value import chain through board-settings.ts (which itself imports credential-contract.js and browse.js) would not resolve under the raw test runner. Concentrating the primitive enums in a dependency-free leaf module lets filter-state.ts import real values by depending on that leaf directly (via an explicit .ts specifier, since it's a new file loaded raw by tests) without needing any other existing file to change its import style. board-settings.ts keeps its original .js specifiers throughout and only gains a re-export of trackerViewSchema/TrackerView from the new leaf. Review fixes folded in: - Added filter-state.ts and work-schemas.ts to package.json files, in alphabetical position; npm pack was previously missing work-schemas.ts even though contract.ts and board-settings.ts both depend on it. - Extended the normalization test to assert every one of the seven array fields sorts and dedupes independently, with a distinct value per field, so a copy/paste cross-wiring in normalizeBoardFilterState fails loudly. - filterStateScopeId no longer aliases an empty scope onto the across-projects row; it now falls through and is rejected downstream by bbProjectIdSchema like any other malformed scope, with a test and a comment noting the (unguarded) collision risk if a real bb project were ever literally named proj_across_projects. - Removed the unused `type WorkStateCategory` from contract.ts's value import block (only the separate `export type` line needs it). - Pinned the strictness test to safeParse + the `unrecognized_keys` issue code instead of an unpredicated assert.throws. --- plugins/taskboard/board-settings.ts | 6 +- plugins/taskboard/contract.ts | 22 +++-- plugins/taskboard/filter-state.ts | 93 ++++++++++++++++++++ plugins/taskboard/package.json | 4 +- plugins/taskboard/test/filter-state.test.ts | 94 +++++++++++++++++++++ plugins/taskboard/work-schemas.ts | 24 ++++++ 6 files changed, 227 insertions(+), 16 deletions(-) create mode 100644 plugins/taskboard/filter-state.ts create mode 100644 plugins/taskboard/test/filter-state.test.ts create mode 100644 plugins/taskboard/work-schemas.ts diff --git a/plugins/taskboard/board-settings.ts b/plugins/taskboard/board-settings.ts index ceb7091..d06c78c 100644 --- a/plugins/taskboard/board-settings.ts +++ b/plugins/taskboard/board-settings.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; import { bbProjectIdSchema } from './credential-contract.js'; import { DEFAULT_WORKFLOW_STATUS_ORDER } from './browse.js'; +import { trackerViewSchema } from './work-schemas.js'; export { DEFAULT_WORKFLOW_STATUS_ORDER } from './browse.js'; - -export const trackerViewSchema = z.enum(['list', 'kanban']); -export type TrackerView = z.infer; +export { trackerViewSchema } from './work-schemas.js'; +export type { TrackerView } from './work-schemas.js'; export const workItemFilterFieldSchema = z.enum([ 'state', diff --git a/plugins/taskboard/contract.ts b/plugins/taskboard/contract.ts index ce08b06..b82d020 100644 --- a/plugins/taskboard/contract.ts +++ b/plugins/taskboard/contract.ts @@ -8,6 +8,16 @@ import { secretMutationSchema } from './credential-contract.js'; import { projectBoardSettingsSchema } from './board-settings.js'; +import { + workSourceSchema, + workStateCategorySchema, + type WorkSource +} from './work-schemas.js'; +export { + workSourceSchema, + workStateCategorySchema +} from './work-schemas.js'; +export type { WorkSource, WorkStateCategory } from './work-schemas.js'; export { DEFAULT_WORK_ITEM_FILTER_FIELDS, DEFAULT_WORKFLOW_STATUS_ORDER, @@ -34,9 +44,6 @@ export type { SecretMutation } from './credential-contract.js'; -export const workSourceSchema = z.enum(['linear', 'github', 'jira']); -export type WorkSource = z.infer; - export const trackerProjectSchema = z .object({ id: bbProjectIdSchema, @@ -83,15 +90,6 @@ export const projectConfigMutationSchema = projectSourceConfigSchema }); export type ProjectConfigMutation = z.infer; -export const workStateCategorySchema = z.enum([ - 'backlog', - 'todo', - 'in_progress', - 'done', - 'canceled' -]); -export type WorkStateCategory = z.infer; - export const workStatusOptionSchema = z .object({ id: z.string().min(1), diff --git a/plugins/taskboard/filter-state.ts b/plugins/taskboard/filter-state.ts new file mode 100644 index 0000000..6e313a3 --- /dev/null +++ b/plugins/taskboard/filter-state.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; +import { + trackerViewSchema, + workSourceSchema, + workStateCategorySchema +} from './work-schemas.ts'; + +export const ALL_SOURCES_FILTER = 'all'; +export const ACROSS_PROJECTS_SCOPE_ID = 'proj_across_projects'; +export const ACROSS_PROJECTS_SCOPE_KEY = 'across-projects'; +export const RIGHT_PANEL_SCOPE_PREFIX = 'right-panel:'; + +export const sourceFilterSchema = z.union([ + z.literal(ALL_SOURCES_FILTER), + workSourceSchema +]); +export type SourceFilterValue = z.infer; + +export const boardFilterStateSchema = z + .object({ + source: sourceFilterSchema, + stateCategories: z.array(workStateCategorySchema), + statuses: z.array(z.string()), + assignees: z.array(z.string()), + priorities: z.array(z.string()), + externalProjects: z.array(z.string()), + labels: z.array(z.string()), + query: z.string(), + view: trackerViewSchema + }) + .strict(); +export type BoardFilterState = z.infer; + +export function defaultBoardFilterState(): BoardFilterState { + return { + source: ALL_SOURCES_FILTER, + stateCategories: [], + statuses: [], + assignees: [], + priorities: [], + externalProjects: [], + labels: [], + query: '', + view: 'list' + }; +} + +/** + * The three surfaces rendering TrackerList use different in-memory scope + * keys, but filter state is persisted per project. The right panel and the + * main panel for one project share a row. + * + * An empty scope is deliberately left unaliased: it is rejected downstream + * by bbProjectIdSchema, the same as any other malformed scope, rather than + * silently overwriting the shared across-projects row. + * + * Note: a real bb project literally named `proj_across_projects` would + * collide with the across-projects row. No guard for that today. + */ +export function filterStateScopeId(scope: string): string { + const bare = scope.startsWith(RIGHT_PANEL_SCOPE_PREFIX) + ? scope.slice(RIGHT_PANEL_SCOPE_PREFIX.length) + : scope; + return bare === ACROSS_PROJECTS_SCOPE_KEY ? ACROSS_PROJECTS_SCOPE_ID : bare; +} + +function uniqueSorted(values: readonly T[]): T[] { + return [...new Set(values)].sort(); +} + +/** + * Filter arrays are sets, so order carries no meaning. Canonicalizing them + * keeps stored rows stable and stops a reorder from looking like a change. + */ +export function normalizeBoardFilterState( + state: BoardFilterState +): BoardFilterState { + return { + source: state.source, + stateCategories: uniqueSorted(state.stateCategories), + statuses: uniqueSorted(state.statuses), + assignees: uniqueSorted(state.assignees), + priorities: uniqueSorted(state.priorities), + externalProjects: uniqueSorted(state.externalProjects), + labels: uniqueSorted(state.labels), + query: state.query.trim(), + view: state.view + }; +} + +export function boardFilterStateFingerprint(state: BoardFilterState): string { + return JSON.stringify(normalizeBoardFilterState(state)); +} diff --git a/plugins/taskboard/package.json b/plugins/taskboard/package.json index 8c60079..b101685 100644 --- a/plugins/taskboard/package.json +++ b/plugins/taskboard/package.json @@ -64,11 +64,13 @@ "create-issue.ts", "credential-contract.ts", "credentials.ts", + "filter-state.ts", "issue-draft.ts", "project-selection.ts", "server.ts", "store.ts", - "tsconfig.json" + "tsconfig.json", + "work-schemas.ts" ], "dependencies": { "@hugeicons/core-free-icons": "^4.1.3", diff --git a/plugins/taskboard/test/filter-state.test.ts b/plugins/taskboard/test/filter-state.test.ts new file mode 100644 index 0000000..0c4d210 --- /dev/null +++ b/plugins/taskboard/test/filter-state.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { z } from 'zod'; +import { + ACROSS_PROJECTS_SCOPE_ID, + boardFilterStateFingerprint, + boardFilterStateSchema, + defaultBoardFilterState, + filterStateScopeId, + normalizeBoardFilterState +} from '../filter-state.ts'; +import type { BoardFilterState } from '../filter-state.ts'; + +function state(overrides: Partial = {}): BoardFilterState { + return { ...defaultBoardFilterState(), ...overrides }; +} + +test('parses a complete filter state', () => { + const parsed = boardFilterStateSchema.parse({ + source: 'linear', + stateCategories: ['todo'], + statuses: ['In Progress'], + assignees: ['Andrii Los'], + priorities: ['High'], + externalProjects: ['Platform'], + labels: ['bug'], + query: 'flake', + view: 'kanban' + }); + assert.equal(parsed.source, 'linear'); + assert.deepEqual(parsed.assignees, ['Andrii Los']); +}); + +test('rejects unknown keys, including committedQuery', () => { + const result = boardFilterStateSchema.safeParse({ + ...defaultBoardFilterState(), + committedQuery: 'flake' + }); + assert.equal(result.success, false); + assert.equal( + (result.error as z.ZodError).issues[0]?.code, + 'unrecognized_keys' + ); +}); + +test('collapses every surface scope key to one storage key', () => { + assert.equal(filterStateScopeId('proj_alpha'), 'proj_alpha'); + assert.equal(filterStateScopeId('right-panel:proj_alpha'), 'proj_alpha'); + assert.equal(filterStateScopeId('across-projects'), ACROSS_PROJECTS_SCOPE_ID); + assert.equal( + filterStateScopeId('right-panel:across-projects'), + ACROSS_PROJECTS_SCOPE_ID + ); +}); + +test('leaves an empty scope unaliased for the RPC boundary to reject', () => { + // filterStateScopeId does not special-case '' onto the across-projects + // row; both of these are rejected downstream by bbProjectIdSchema + // (startsWith('proj_')), the same as any other malformed scope. + assert.equal(filterStateScopeId(''), ''); + assert.equal(filterStateScopeId('right-panel:'), ''); +}); + +test('normalizes every array field independently and trims the query', () => { + const normalized = normalizeBoardFilterState( + state({ + stateCategories: ['todo', 'backlog', 'todo'], + statuses: ['Todo', 'Backlog', 'Todo'], + assignees: ['Bob', 'Alice', 'Bob'], + priorities: ['Low', 'High', 'Low'], + externalProjects: ['Web', 'Api', 'Web'], + labels: ['ui', 'bug', 'ui'], + query: ' flake ' + }) + ); + assert.deepEqual(normalized.stateCategories, ['backlog', 'todo']); + assert.deepEqual(normalized.statuses, ['Backlog', 'Todo']); + assert.deepEqual(normalized.assignees, ['Alice', 'Bob']); + assert.deepEqual(normalized.priorities, ['High', 'Low']); + assert.deepEqual(normalized.externalProjects, ['Api', 'Web']); + assert.deepEqual(normalized.labels, ['bug', 'ui']); + assert.equal(normalized.query, 'flake'); +}); + +test('fingerprints ignore array order', () => { + assert.equal( + boardFilterStateFingerprint(state({ labels: ['ui', 'bug'] })), + boardFilterStateFingerprint(state({ labels: ['bug', 'ui'] })) + ); + assert.notEqual( + boardFilterStateFingerprint(state({ labels: ['ui'] })), + boardFilterStateFingerprint(state({ labels: ['bug'] })) + ); +}); diff --git a/plugins/taskboard/work-schemas.ts b/plugins/taskboard/work-schemas.ts new file mode 100644 index 0000000..07dc759 --- /dev/null +++ b/plugins/taskboard/work-schemas.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +/** + * Leaf module for the plugin's primitive enums. It has no local imports of + * its own, so any module loaded directly by `node --test + * --experimental-strip-types` (rather than through the bundled plugin) can + * depend on it without dragging in a chain of `.js` specifiers the raw + * test runner cannot resolve to their `.ts` sources. + */ + +export const workSourceSchema = z.enum(['linear', 'github', 'jira']); +export type WorkSource = z.infer; + +export const workStateCategorySchema = z.enum([ + 'backlog', + 'todo', + 'in_progress', + 'done', + 'canceled' +]); +export type WorkStateCategory = z.infer; + +export const trackerViewSchema = z.enum(['list', 'kanban']); +export type TrackerView = z.infer; From 879d7206f90aad935f360ed4a45d2f6583584b5e Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Thu, 20 Aug 2026 22:30:59 +0200 Subject: [PATCH 02/13] Store Taskboard filter state per project --- plugins/taskboard/contract.ts | 14 ++++++++ plugins/taskboard/store.ts | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/plugins/taskboard/contract.ts b/plugins/taskboard/contract.ts index b82d020..c0db852 100644 --- a/plugins/taskboard/contract.ts +++ b/plugins/taskboard/contract.ts @@ -31,6 +31,20 @@ export type { TrackerView, WorkItemFilterField } from './board-settings.js'; +export { + ACROSS_PROJECTS_SCOPE_ID, + ALL_SOURCES_FILTER, + boardFilterStateFingerprint, + boardFilterStateSchema, + defaultBoardFilterState, + filterStateScopeId, + normalizeBoardFilterState, + sourceFilterSchema +} from './filter-state.js'; +export type { + BoardFilterState, + SourceFilterValue +} from './filter-state.js'; export { bbProjectIdSchema, jiraBaseUrlSchema, diff --git a/plugins/taskboard/store.ts b/plugins/taskboard/store.ts index 8c78f2c..d8eda1b 100644 --- a/plugins/taskboard/store.ts +++ b/plugins/taskboard/store.ts @@ -1,9 +1,13 @@ import type { BbPluginApi } from '@get-bb/plugin-sdk'; import { + bbProjectIdSchema, + boardFilterStateSchema, defaultProjectBoardSettings, + normalizeBoardFilterState, projectBoardSettingsSchema, projectSourceConfigSchema, workItemSchema, + type BoardFilterState, type ProjectBoardSettings, type ProjectSourceConfig, type WorkItem, @@ -55,6 +59,11 @@ interface ProjectBoardSettingsRow { status_order_json: string; } +interface ProjectFilterStateRow { + bb_project_id: string; + filters_json: string; +} + export interface StoredSyncState { lastSyncedAt: string | null; error: string | null; @@ -122,6 +131,14 @@ function escapeLike(value: string): string { return value.replace(/[\\%_]/gu, character => `\\${character}`); } +function parseJsonSafely(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + export function createWorkItemStore(bb: BbPluginApi) { const db = bb.storage.database(); bb.storage.migrate(db, [ @@ -323,6 +340,13 @@ export function createWorkItemStore(bb: BbPluginApi) { status_order_json TEXT NOT NULL, updated_at TEXT NOT NULL ); + `, + ` + CREATE TABLE project_filter_state ( + bb_project_id TEXT PRIMARY KEY, + filters_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); ` ]); @@ -433,6 +457,12 @@ export function createWorkItemStore(bb: BbPluginApi) { WHERE bb_project_id = ? `); + const readProjectFilterState = db.prepare<[string], ProjectFilterStateRow>(` + SELECT bb_project_id, filters_json + FROM project_filter_state + WHERE bb_project_id = ? + `); + const clearSourceTransaction = db.transaction( (projectId: string, source: WorkSource) => { db.prepare<[string, WorkSource]>( @@ -683,6 +713,39 @@ export function createWorkItemStore(bb: BbPluginApi) { readProjectBoardSettings.get(settings.projectId)! ); }, + boardFilterState(projectId: string): BoardFilterState | null { + bbProjectIdSchema.parse(projectId); + const row = readProjectFilterState.get(projectId); + if (!row) return null; + // A row whose JSON is malformed or no longer matches the schema + // returns null rather than throwing, so a corrupt write or a future + // schema change degrades to "no saved filters" instead of breaking + // the board. + const raw = parseJsonSafely(row.filters_json); + if (raw === undefined) return null; + const parsed = boardFilterStateSchema.safeParse(raw); + return parsed.success ? normalizeBoardFilterState(parsed.data) : null; + }, + saveBoardFilterState( + projectId: string, + input: BoardFilterState + ): BoardFilterState { + bbProjectIdSchema.parse(projectId); + const state = normalizeBoardFilterState( + boardFilterStateSchema.parse(input) + ); + db.prepare<[string, string, string]>( + ` + INSERT INTO project_filter_state ( + bb_project_id, filters_json, updated_at + ) VALUES (?, ?, ?) + ON CONFLICT(bb_project_id) DO UPDATE SET + filters_json = excluded.filters_json, + updated_at = excluded.updated_at + ` + ).run(projectId, JSON.stringify(state), new Date().toISOString()); + return state; + }, configuredProjectIds(): string[] { return db .prepare<[], { bb_project_id: string }>( From 70f78342a43680b76128ef6fc722eeeea9304f29 Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Fri, 21 Aug 2026 00:47:17 +0200 Subject: [PATCH 03/13] Add Taskboard filter state RPC methods --- plugins/taskboard/contract.ts | 14 ++++++++++++++ plugins/taskboard/server.ts | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/plugins/taskboard/contract.ts b/plugins/taskboard/contract.ts index c0db852..9ba2371 100644 --- a/plugins/taskboard/contract.ts +++ b/plugins/taskboard/contract.ts @@ -8,6 +8,7 @@ import { secretMutationSchema } from './credential-contract.js'; import { projectBoardSettingsSchema } from './board-settings.js'; +import { boardFilterStateSchema } from './filter-state.js'; import { workSourceSchema, workStateCategorySchema, @@ -365,6 +366,19 @@ export const taskboardRpcContract = defineRpcContract({ saveProjectBoardSettings: { input: projectBoardSettingsSchema, output: z.object({ settings: projectBoardSettingsSchema }).strict() + }, + getBoardFilterState: { + input: z.object({ projectId: bbProjectIdSchema }).strict(), + output: z.object({ state: boardFilterStateSchema.nullable() }).strict() + }, + saveBoardFilterState: { + input: z + .object({ + projectId: bbProjectIdSchema, + state: boardFilterStateSchema + }) + .strict(), + output: z.object({ state: boardFilterStateSchema }).strict() } }); diff --git a/plugins/taskboard/server.ts b/plugins/taskboard/server.ts index b326260..b0b18be 100644 --- a/plugins/taskboard/server.ts +++ b/plugins/taskboard/server.ts @@ -1,5 +1,6 @@ import type { BbPluginApi, PluginRpcHandlers } from '@get-bb/plugin-sdk'; import { + ACROSS_PROJECTS_SCOPE_ID, bbProjectIdSchema, formatWorkItemContext, issueDraftRecordSchema, @@ -593,6 +594,11 @@ export default async function plugin(bb: BbPluginApi) { await projectById(projectId); } + async function assertFilterScopeExists(scopeId: string): Promise { + if (scopeId === ACROSS_PROJECTS_SCOPE_ID) return; + await assertProjectExists(scopeId); + } + async function fallbackGithubRepos(projectId: string): Promise { const project = (await liveProjects()).find( entry => entry.id === projectId @@ -1728,6 +1734,16 @@ export default async function plugin(bb: BbPluginApi) { source: null }); return { settings }; + }, + async getBoardFilterState(input) { + await assertFilterScopeExists(input.projectId); + return { state: store.boardFilterState(input.projectId) }; + }, + async saveBoardFilterState(input) { + await assertFilterScopeExists(input.projectId); + return { + state: store.saveBoardFilterState(input.projectId, input.state) + }; } }; bb.rpc.register(taskboardRpcContract, handlers); From 21893aa53627bfb0669c5bef06e18cd70f28b621 Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Fri, 21 Aug 2026 00:59:33 +0200 Subject: [PATCH 04/13] Restore Taskboard filters on open --- plugins/taskboard/app.tsx | 170 +++++++++++++++++++++++++++++++------- 1 file changed, 141 insertions(+), 29 deletions(-) diff --git a/plugins/taskboard/app.tsx b/plugins/taskboard/app.tsx index 71118c6..98bd104 100644 --- a/plugins/taskboard/app.tsx +++ b/plugins/taskboard/app.tsx @@ -61,20 +61,25 @@ import { TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import type { - ProjectConfigMutation, - ProjectConfigView, - ProjectCredentialsInteractionResponse, - CreateIssueContext, - IssueDraftRecord, - SecretMutation, - TrackerProject, - WorkItem, - WorkItemDetail, - WorkSource, - WorkStateCategory, - WorkStatusOption, - TaskboardRpcContract +import { + ACROSS_PROJECTS_SCOPE_ID, + ALL_SOURCES_FILTER, + boardFilterStateFingerprint, + filterStateScopeId, + type BoardFilterState, + type ProjectConfigMutation, + type ProjectConfigView, + type ProjectCredentialsInteractionResponse, + type CreateIssueContext, + type IssueDraftRecord, + type SecretMutation, + type TrackerProject, + type WorkItem, + type WorkItemDetail, + type WorkSource, + type WorkStateCategory, + type WorkStatusOption, + type TaskboardRpcContract } from './contract.js'; import { defaultProjectBoardSettings, @@ -115,7 +120,7 @@ import './app.css'; const PANEL_PATH = 'tasks'; const THREAD_PANEL_ACTION_ID = 'taskboard-panel'; -const ALL_SOURCES = 'all'; +const ALL_SOURCES = ALL_SOURCES_FILTER; const RIGHT_PANEL_PINNED_STORAGE_KEY = 'bb-taskboard:right-panel-pinned'; const RIGHT_PANEL_PIN_EVENT = 'bb-taskboard:right-panel-pin-changed'; const SIDEBAR_COLLAPSED_STORAGE_KEY = 'bb-taskboard:sidebar-collapsed'; @@ -2926,7 +2931,7 @@ function TrackerList({ const rpc = useRpc(); const [items, setItems] = useState(); const [boardSettings, setBoardSettings] = useState(() => - defaultProjectBoardSettings(projectId ?? 'proj_across_projects') + defaultProjectBoardSettings(projectId ?? ACROSS_PROJECTS_SCOPE_ID) ); const [boardSettingsReady, setBoardSettingsReady] = useState( projectId === null @@ -2963,34 +2968,72 @@ function TrackerList({ ); const [error, setError] = useState(null); const requestRevisionRef = useRef(0); + const savedFingerprintRef = useRef(null); + const filterStateLoadedRef = useRef(false); + const saveRevisionRef = useRef(0); + const savePromiseRef = useRef>(Promise.resolve()); const stateFilterEnabled = boardSettings.enabledFilters.includes('state'); + const storageScopeId = filterStateScopeId(preferenceScope); + useEffect(() => { - if (projectId === null) { - setBoardSettings(defaultProjectBoardSettings('proj_across_projects')); - setBoardSettingsReady(true); - return; - } let cancelled = false; setBoardSettingsReady(false); - void rpc - .call('getProjectBoardSettings', { projectId }) - .then(result => { + filterStateLoadedRef.current = false; + const settingsProjectId = projectId ?? ACROSS_PROJECTS_SCOPE_ID; + const loadSettings = + projectId === null + ? Promise.resolve({ + settings: defaultProjectBoardSettings(settingsProjectId) + }) + : rpc.call('getProjectBoardSettings', { projectId }); + + void Promise.all([ + loadSettings, + rpc.call('getBoardFilterState', { projectId: storageScopeId }) + ]) + .then(([settingsResult, stateResult]) => { if (cancelled) return; - setBoardSettings(result.settings); - if (!initialPreferences) setView(result.settings.defaultView); + setBoardSettings(settingsResult.settings); + // Only a successful load may open this gate. A failed load (below, + // in .catch()) must NOT set this: the save effect would then be + // free to write the still-default in-memory state over a saved row + // it never actually read, destroying data it merely failed to + // fetch. Every path from here on is a load we positively trust + // (either "keep in-memory preferences" or "we know what's saved, + // including that nothing is saved"), so it is safe to set once, + // up front, rather than at each return below. + filterStateLoadedRef.current = true; + if (initialPreferences) return; + const saved = stateResult.state; + if (!saved) { + setView(settingsResult.settings.defaultView); + return; + } + setSource(projectId === null ? saved.source : ALL_SOURCES); + setStateCategories(saved.stateCategories); + setStatuses(saved.statuses); + setAssignees(saved.assignees); + setPriorities(saved.priorities); + setExternalProjects(saved.externalProjects); + setLabels(saved.labels); + setQuery(saved.query); + setCommittedQuery(saved.query); + setView(saved.view); + savedFingerprintRef.current = boardFilterStateFingerprint(saved); }) .catch(() => { if (cancelled) return; - setBoardSettings(defaultProjectBoardSettings(projectId)); + setBoardSettings(defaultProjectBoardSettings(settingsProjectId)); }) .finally(() => { - if (!cancelled) setBoardSettingsReady(true); + if (cancelled) return; + setBoardSettingsReady(true); }); return () => { cancelled = true; }; - }, [initialPreferences, projectId, rpc]); + }, [initialPreferences, projectId, rpc, storageScopeId]); const loadItems = useCallback(async () => { const requestRevision = ++requestRevisionRef.current; @@ -3061,6 +3104,75 @@ function TrackerList({ statuses, view ]); + useEffect(() => { + if (!filterStateLoadedRef.current) return; + const state: BoardFilterState = { + source, + stateCategories, + statuses, + assignees, + priorities, + externalProjects, + labels, + query, + view + }; + const fingerprint = boardFilterStateFingerprint(state); + if (fingerprint === savedFingerprintRef.current) return; + const timeout = window.setTimeout(() => { + savedFingerprintRef.current = fingerprint; + const saveRevision = ++saveRevisionRef.current; + // The transport gives no ordering guarantee -- each rpc.call is an + // independent request, and the server handler awaits a + // variable-latency project lookup before its write -- so two saves + // fired close together could otherwise commit out of edit order. + // Chaining onto savePromiseRef serializes them: the next save's + // request is only issued once the previous one has fully settled, + // so the server always commits in the order the user made the + // edits. The leading .catch(() => {}) is not a swallowed error -- + // it stops one failed save from poisoning the chain and skipping + // every save queued after it. + // + // The fingerprint above is set optimistically, before the request + // is issued, so it reads as "saved" for the duration of the call. + // Nothing outside this effect reads it, so the worst case is one + // redundant re-send. + // + // Known limitation: rpc.call takes no abort signal, so a request + // that never settles stalls the chain and silently stops + // persisting until this component remounts. Racing it against a + // timeout would not help -- it cannot cancel the request, so a + // second save would go out while the first is still in flight and + // reintroduce the out-of-order commit this chain exists to + // prevent. + savePromiseRef.current = savePromiseRef.current + .catch(() => {}) + .then(() => + rpc.call('saveBoardFilterState', { projectId: storageScopeId, state }) + ) + .catch(nextError => { + // Deliberately no toast: a filter save is incidental to what + // the user is doing. Log it anyway, so a schema or contract + // bug is distinguishable from a transient network failure. + console.warn('Taskboard: filter state save failed', nextError); + if (saveRevision !== saveRevisionRef.current) return; + savedFingerprintRef.current = null; + }); + }, 500); + return () => window.clearTimeout(timeout); + }, [ + assignees, + externalProjects, + labels, + priorities, + query, + rpc, + source, + stateCategories, + statuses, + storageScopeId, + view + ]); useEffect( () => () => { requestRevisionRef.current += 1; From 462b55f4eea32fc3a100a633121b407a5338bbae Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Fri, 21 Aug 2026 17:10:53 +0200 Subject: [PATCH 05/13] Document Taskboard sticky filters --- plugins/taskboard/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/taskboard/README.md b/plugins/taskboard/README.md index 93a600e..ad8ad7e 100644 --- a/plugins/taskboard/README.md +++ b/plugins/taskboard/README.md @@ -34,6 +34,10 @@ task to an agent without rebuilding context by hand. the default List or Kanban layout, and the exact provider status order. Provider-native workflow groups, drag-and-drop, and keyboard status moves remain available without repetitive provider chips on every task. +- **Filters that stay put** — each project remembers its own filter chips, + search text, and List or Kanban choice. They come back on reload and follow + you between the board and a thread's Taskboard panel, because they are saved + per project rather than per surface. "Clear filters" resets them. - **Live task details** — cached summaries keep browsing fast; opening a task fetches its current description, labels, assignee, and comments. - **Pinned beside every chat** — open Taskboard from the thread-header button, From 445e5ac4f3943a8eb4821f27a808715f46e57450 Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Sat, 22 Aug 2026 01:07:22 +0200 Subject: [PATCH 06/13] Stop discarding saved filters on a mid-flight reload Returning to the board from a thread cleared that project's filters while leaving other projects intact. The saved row was never corrupted: the state was fetched correctly and then thrown away. The parent re-reads initialPreferences from a mutable Map on every render and passes it as a prop, and it sat in the load effect's dependency array. The effect that records preferences ran on mount, before the load resolved, so it cached an empty placeholder. That flipped initialPreferences from undefined to a truthy empty object, re-ran the load, cancelled the in-flight fetch, and the second pass then hit `if (initialPreferences) return` and refused to apply the saved state. Two fetches, zero applications. Switching projects was unaffected because it changes projectId, landing on a scope whose cache entry was absent or already real. Only the thread round trip re-rendered the parent without changing project. - Capture initialPreferences in a ref at mount. It seeds useState; it has no business re-running the load. - Refuse to cache a preferences snapshot until the load has resolved, so an empty placeholder can never masquerade as in-session state. Confirmed against the running plugin: one getBoardFilterState per mount instead of two, and the filters survive the round trip. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/taskboard/app.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/plugins/taskboard/app.tsx b/plugins/taskboard/app.tsx index 98bd104..7891528 100644 --- a/plugins/taskboard/app.tsx +++ b/plugins/taskboard/app.tsx @@ -2968,6 +2968,12 @@ function TrackerList({ ); const [error, setError] = useState(null); const requestRevisionRef = useRef(0); + // initialPreferences seeds useState at mount. It must NOT drive the load + // effect: the parent re-reads it from a mutable Map on every render, so its + // identity flips as soon as this component records its own preferences, + // which re-ran the load, cancelled the in-flight fetch, and then skipped + // applying the saved state. Capture it once instead. + const initialPreferencesRef = useRef(initialPreferences); const savedFingerprintRef = useRef(null); const filterStateLoadedRef = useRef(false); const saveRevisionRef = useRef(0); @@ -3004,7 +3010,7 @@ function TrackerList({ // including that nothing is saved"), so it is safe to set once, // up front, rather than at each return below. filterStateLoadedRef.current = true; - if (initialPreferences) return; + if (initialPreferencesRef.current) return; const saved = stateResult.state; if (!saved) { setView(settingsResult.settings.defaultView); @@ -3033,7 +3039,7 @@ function TrackerList({ return () => { cancelled = true; }; - }, [initialPreferences, projectId, rpc, storageScopeId]); + }, [projectId, rpc, storageScopeId]); const loadItems = useCallback(async () => { const requestRevision = ++requestRevisionRef.current; @@ -3078,6 +3084,11 @@ function TrackerList({ return () => window.clearTimeout(timeout); }, [query]); useEffect(() => { + // Do not cache a snapshot before the load has resolved. The parent uses + // this map to seed a later mount, and an empty pre-load placeholder would + // make that mount look like it had real in-session state, suppressing the + // saved filters entirely. + if (!filterStateLoadedRef.current) return; onPreferencesChange(preferenceScope, { source, stateCategories, From 6b97bd858d6e3dff4543d11485fd2b870f451845 Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Fri, 21 Aug 2026 17:13:54 +0200 Subject: [PATCH 07/13] Add Taskboard filter preset module --- plugins/taskboard/filter-presets.ts | 56 +++++++++++++++++ plugins/taskboard/package.json | 1 + plugins/taskboard/test/filter-presets.test.ts | 62 +++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 plugins/taskboard/filter-presets.ts create mode 100644 plugins/taskboard/test/filter-presets.test.ts diff --git a/plugins/taskboard/filter-presets.ts b/plugins/taskboard/filter-presets.ts new file mode 100644 index 0000000..046144a --- /dev/null +++ b/plugins/taskboard/filter-presets.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; +import { bbProjectIdSchema } from './credential-contract.ts'; +import { boardFilterStateSchema } from './filter-state.ts'; + +export const PRESET_NAME_MAX_LENGTH = 60; + +export const filterPresetNameSchema = z + .string() + .trim() + .min(1) + .max(PRESET_NAME_MAX_LENGTH); + +export const filterPresetSchema = z + .object({ + id: z.string().min(1), + projectId: bbProjectIdSchema, + name: filterPresetNameSchema, + state: boardFilterStateSchema, + position: z.number().int().nonnegative() + }) + .strict(); +export type FilterPreset = z.infer; + +/** + * Names collide case-insensitively. This is stored as `name_normalized` and + * backs a UNIQUE constraint, so it must not depend on the host locale: + * `toLocaleLowerCase()` maps Turkish dotted and dotless I differently under + * `tr-TR` than elsewhere, which would make two names collide on one machine + * and not another, and leave stored values disagreeing with freshly computed + * ones. `board-settings.ts` uses the locale-aware form for status names, but + * only as an in-memory validation check whose result is never persisted. + */ +export function normalizePresetName(name: string): string { + return name.trim().toLowerCase(); +} + +/** + * A reorder must be a permutation of the stored ids. Anything else means the + * client is working from a stale list, so reject rather than guess. + */ +export function resolvePresetOrder( + currentIds: readonly string[], + requestedIds: readonly string[] +): string[] { + if (requestedIds.length !== currentIds.length) { + throw new Error('Preset order must list every preset exactly once'); + } + const current = new Set(currentIds); + const seen = new Set(); + for (const id of requestedIds) { + if (!current.has(id)) throw new Error(`Unknown filter preset: ${id}`); + if (seen.has(id)) throw new Error(`Duplicate filter preset: ${id}`); + seen.add(id); + } + return [...requestedIds]; +} diff --git a/plugins/taskboard/package.json b/plugins/taskboard/package.json index b101685..a74fca1 100644 --- a/plugins/taskboard/package.json +++ b/plugins/taskboard/package.json @@ -64,6 +64,7 @@ "create-issue.ts", "credential-contract.ts", "credentials.ts", + "filter-presets.ts", "filter-state.ts", "issue-draft.ts", "project-selection.ts", diff --git a/plugins/taskboard/test/filter-presets.test.ts b/plugins/taskboard/test/filter-presets.test.ts new file mode 100644 index 0000000..903e4cc --- /dev/null +++ b/plugins/taskboard/test/filter-presets.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { defaultBoardFilterState } from '../filter-state.ts'; +import { + filterPresetSchema, + normalizePresetName, + resolvePresetOrder +} from '../filter-presets.ts'; + +test('normalizes names for collision checks', () => { + assert.equal(normalizePresetName(' My Work '), 'my work'); + assert.equal(normalizePresetName('MY WORK'), normalizePresetName('my work')); +}); + +test('accepts a valid preset', () => { + const preset = filterPresetSchema.parse({ + id: 'fp_1', + projectId: 'proj_alpha', + name: 'My work', + state: defaultBoardFilterState(), + position: 0 + }); + assert.equal(preset.name, 'My work'); +}); + +test('rejects empty and overlong names', () => { + const base = { + id: 'fp_1', + projectId: 'proj_alpha', + state: defaultBoardFilterState(), + position: 0 + }; + assert.throws(() => filterPresetSchema.parse({ ...base, name: ' ' })); + assert.throws(() => + filterPresetSchema.parse({ ...base, name: 'x'.repeat(61) }) + ); +}); + +test('resolves a reorder that lists every preset once', () => { + assert.deepEqual( + resolvePresetOrder(['a', 'b', 'c'], ['c', 'a', 'b']), + ['c', 'a', 'b'] + ); +}); + +test('rejects incomplete, unknown, and duplicated reorders', () => { + assert.throws(() => resolvePresetOrder(['a', 'b'], ['a'])); + assert.throws(() => resolvePresetOrder(['a', 'b'], ['a', 'z'])); + assert.throws(() => resolvePresetOrder(['a', 'b'], ['a', 'a'])); +}); + +test('normalization is locale-independent', () => { + // name_normalized is persisted and backs a UNIQUE constraint, so it must + // produce the same bytes on every machine. Pin the expected codepoints + // rather than comparing two implementations: U+0130 lowercases to + // 'i' + U+0307 under Unicode's locale-independent mapping, but to a bare + // 'i' under tr-TR. Note this assertion cannot fail on a host whose locale + // already agrees with the default mapping; it is here to fail loudly on a + // Turkish host if someone reintroduces toLocaleLowerCase. + assert.equal(normalizePresetName('\u0130'), 'i\u0307'); + assert.equal(normalizePresetName(' \u0130S '), 'i\u0307s'); +}); From 37995f1453cc59ca852c4d9624277a2eb0bba7bd Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Fri, 21 Aug 2026 17:23:18 +0200 Subject: [PATCH 08/13] Store Taskboard filter presets per project --- plugins/taskboard/contract.ts | 8 ++ plugins/taskboard/store.ts | 213 ++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) diff --git a/plugins/taskboard/contract.ts b/plugins/taskboard/contract.ts index 9ba2371..506c9e3 100644 --- a/plugins/taskboard/contract.ts +++ b/plugins/taskboard/contract.ts @@ -46,6 +46,14 @@ export type { BoardFilterState, SourceFilterValue } from './filter-state.js'; +export { + PRESET_NAME_MAX_LENGTH, + filterPresetNameSchema, + filterPresetSchema, + normalizePresetName, + resolvePresetOrder +} from './filter-presets.js'; +export type { FilterPreset } from './filter-presets.js'; export { bbProjectIdSchema, jiraBaseUrlSchema, diff --git a/plugins/taskboard/store.ts b/plugins/taskboard/store.ts index d8eda1b..a87b501 100644 --- a/plugins/taskboard/store.ts +++ b/plugins/taskboard/store.ts @@ -1,13 +1,19 @@ +import { randomUUID } from 'node:crypto'; import type { BbPluginApi } from '@get-bb/plugin-sdk'; import { bbProjectIdSchema, boardFilterStateSchema, defaultProjectBoardSettings, + filterPresetNameSchema, + filterPresetSchema, normalizeBoardFilterState, + normalizePresetName, projectBoardSettingsSchema, projectSourceConfigSchema, + resolvePresetOrder, workItemSchema, type BoardFilterState, + type FilterPreset, type ProjectBoardSettings, type ProjectSourceConfig, type WorkItem, @@ -64,6 +70,14 @@ interface ProjectFilterStateRow { filters_json: string; } +interface FilterPresetRow { + id: string; + bb_project_id: string; + name: string; + filters_json: string; + position: number; +} + export interface StoredSyncState { lastSyncedAt: string | null; error: string | null; @@ -127,6 +141,19 @@ function boardSettingsFromRow( }); } +function filterPresetFromRow(row: FilterPresetRow): FilterPreset | null { + const state = parseJsonSafely(row.filters_json); + if (state === undefined) return null; + const parsed = filterPresetSchema.safeParse({ + id: row.id, + projectId: row.bb_project_id, + name: row.name, + state, + position: row.position + }); + return parsed.success ? parsed.data : null; +} + function escapeLike(value: string): string { return value.replace(/[\\%_]/gu, character => `\\${character}`); } @@ -347,6 +374,22 @@ export function createWorkItemStore(bb: BbPluginApi) { filters_json TEXT NOT NULL, updated_at TEXT NOT NULL ); + `, + ` + CREATE TABLE project_filter_presets ( + id TEXT PRIMARY KEY, + bb_project_id TEXT NOT NULL, + name TEXT NOT NULL, + name_normalized TEXT NOT NULL, + filters_json TEXT NOT NULL, + position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (bb_project_id, name_normalized) + ); + + CREATE INDEX idx_filter_presets_project + ON project_filter_presets(bb_project_id, position); ` ]); @@ -463,6 +506,19 @@ export function createWorkItemStore(bb: BbPluginApi) { WHERE bb_project_id = ? `); + const readFilterPresets = db.prepare<[string], FilterPresetRow>(` + SELECT id, bb_project_id, name, filters_json, position + FROM project_filter_presets + WHERE bb_project_id = ? + ORDER BY position ASC + `); + + const readFilterPreset = db.prepare<[string, string], FilterPresetRow>(` + SELECT id, bb_project_id, name, filters_json, position + FROM project_filter_presets + WHERE bb_project_id = ? AND id = ? + `); + const clearSourceTransaction = db.transaction( (projectId: string, source: WorkSource) => { db.prepare<[string, WorkSource]>( @@ -746,6 +802,163 @@ export function createWorkItemStore(bb: BbPluginApi) { ).run(projectId, JSON.stringify(state), new Date().toISOString()); return state; }, + listFilterPresets(projectId: string): FilterPreset[] { + bbProjectIdSchema.parse(projectId); + return readFilterPresets + .all(projectId) + .map(filterPresetFromRow) + .filter((preset): preset is FilterPreset => preset !== null); + }, + saveFilterPreset(input: { + projectId: string; + id?: string; + name: string; + state: BoardFilterState; + }): FilterPreset { + bbProjectIdSchema.parse(input.projectId); + const name = filterPresetNameSchema.parse(input.name); + const normalized = normalizePresetName(name); + const state = normalizeBoardFilterState( + boardFilterStateSchema.parse(input.state) + ); + const now = new Date().toISOString(); + function readSavedPreset(id: string): FilterPreset { + const saved = filterPresetFromRow( + readFilterPreset.get(input.projectId, id)! + ); + if (!saved) { + throw new Error('Saved filter preset could not be read back'); + } + return saved; + } + const conflict = db + .prepare<[string, string], { id: string; name: string }>( + ` + SELECT id, name + FROM project_filter_presets + WHERE bb_project_id = ? AND name_normalized = ? + ` + ) + .get(input.projectId, normalized); + if (conflict && conflict.id !== input.id) { + throw new Error( + `A filter preset named "${conflict.name}" already exists` + ); + } + + if (input.id) { + const existing = readFilterPreset.get(input.projectId, input.id); + if (!existing) throw new Error(`Unknown filter preset: ${input.id}`); + db.prepare<[string, string, string, string, string, string]>( + ` + UPDATE project_filter_presets + SET name = ?, name_normalized = ?, filters_json = ?, updated_at = ? + WHERE bb_project_id = ? AND id = ? + ` + ).run( + name, + normalized, + JSON.stringify(state), + now, + input.projectId, + input.id + ); + return readSavedPreset(input.id); + } + + const id = `fp_${randomUUID().replaceAll('-', '')}`; + const position = readFilterPresets.all(input.projectId).length; + db.prepare< + [string, string, string, string, string, number, string, string] + >( + ` + INSERT INTO project_filter_presets ( + id, bb_project_id, name, name_normalized, filters_json, position, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ` + ).run( + id, + input.projectId, + name, + normalized, + JSON.stringify(state), + position, + now, + now + ); + return readSavedPreset(id); + }, + deleteFilterPreset(projectId: string, id: string): FilterPreset[] { + bbProjectIdSchema.parse(projectId); + return db.transaction(() => { + db.prepare<[string, string]>( + `DELETE FROM project_filter_presets + WHERE bb_project_id = ? AND id = ?` + ).run(projectId, id); + // Renumber every remaining row, including any that fail to parse: + // an unreadable row is invisible to clients but still occupies a + // position, so leaving it out here would let it collide with a + // visible row forever instead of just until the next delete. + const remaining = readFilterPresets.all(projectId); + const now = new Date().toISOString(); + const updatePosition = db.prepare<[number, string, string, string]>( + ` + UPDATE project_filter_presets + SET position = ?, updated_at = ? + WHERE bb_project_id = ? AND id = ? + ` + ); + remaining.forEach((row, index) => { + if (row.position === index) return; + updatePosition.run(index, now, projectId, row.id); + }); + return readFilterPresets + .all(projectId) + .map(filterPresetFromRow) + .filter((preset): preset is FilterPreset => preset !== null); + })(); + }, + reorderFilterPresets( + projectId: string, + ids: readonly string[] + ): FilterPreset[] { + bbProjectIdSchema.parse(projectId); + return db.transaction(() => { + const rows = readFilterPresets.all(projectId); + // A client can only ever request an order for presets it was + // shown, and listFilterPresets hides rows that fail to parse. So + // validate against, and renumber, only the parseable subset — + // otherwise one corrupt row permanently blocks every reorder for + // this project, since resolvePresetOrder demands an exact + // permutation of every stored id. An unreadable row keeps its old + // position and may end up sharing it with a visible row; that is + // harmless because the corrupt row is never displayed, and the + // next delete renumbers every row (visible or not) contiguously + // from 0 anyway. + const currentIds = rows + .filter(row => filterPresetFromRow(row) !== null) + .map(row => row.id); + const ordered = resolvePresetOrder(currentIds, ids); + const positionById = new Map(rows.map(row => [row.id, row.position])); + const now = new Date().toISOString(); + const updatePosition = db.prepare<[number, string, string, string]>( + ` + UPDATE project_filter_presets + SET position = ?, updated_at = ? + WHERE bb_project_id = ? AND id = ? + ` + ); + ordered.forEach((id, index) => { + if (positionById.get(id) === index) return; + updatePosition.run(index, now, projectId, id); + }); + return readFilterPresets + .all(projectId) + .map(filterPresetFromRow) + .filter((preset): preset is FilterPreset => preset !== null); + })(); + }, configuredProjectIds(): string[] { return db .prepare<[], { bb_project_id: string }>( From 39ba70777478cc1143ceb4da06af05a8e31dd571 Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Fri, 21 Aug 2026 17:38:58 +0200 Subject: [PATCH 09/13] Add Taskboard filter preset RPC methods --- plugins/taskboard/contract.ts | 39 +++++++++++++++++++++++++++++++++++ plugins/taskboard/server.ts | 26 +++++++++++++++++++++++ plugins/taskboard/store.ts | 4 ++++ 3 files changed, 69 insertions(+) diff --git a/plugins/taskboard/contract.ts b/plugins/taskboard/contract.ts index 506c9e3..a8d22fe 100644 --- a/plugins/taskboard/contract.ts +++ b/plugins/taskboard/contract.ts @@ -9,6 +9,10 @@ import { } from './credential-contract.js'; import { projectBoardSettingsSchema } from './board-settings.js'; import { boardFilterStateSchema } from './filter-state.js'; +import { + filterPresetNameSchema, + filterPresetSchema +} from './filter-presets.js'; import { workSourceSchema, workStateCategorySchema, @@ -387,6 +391,41 @@ export const taskboardRpcContract = defineRpcContract({ }) .strict(), output: z.object({ state: boardFilterStateSchema }).strict() + }, + listFilterPresets: { + input: z.object({ projectId: bbProjectIdSchema }).strict(), + output: z.object({ presets: z.array(filterPresetSchema) }).strict() + }, + saveFilterPreset: { + input: z + .object({ + projectId: bbProjectIdSchema, + id: z.string().min(1).optional(), + name: filterPresetNameSchema, + state: boardFilterStateSchema + }) + .strict(), + output: z + .object({ + preset: filterPresetSchema, + presets: z.array(filterPresetSchema) + }) + .strict() + }, + deleteFilterPreset: { + input: z + .object({ projectId: bbProjectIdSchema, id: z.string().min(1) }) + .strict(), + output: z.object({ presets: z.array(filterPresetSchema) }).strict() + }, + reorderFilterPresets: { + input: z + .object({ + projectId: bbProjectIdSchema, + ids: z.array(z.string().min(1)) + }) + .strict(), + output: z.object({ presets: z.array(filterPresetSchema) }).strict() } }); diff --git a/plugins/taskboard/server.ts b/plugins/taskboard/server.ts index b0b18be..6109c8e 100644 --- a/plugins/taskboard/server.ts +++ b/plugins/taskboard/server.ts @@ -1744,6 +1744,32 @@ export default async function plugin(bb: BbPluginApi) { return { state: store.saveBoardFilterState(input.projectId, input.state) }; + }, + async listFilterPresets(input) { + await assertFilterScopeExists(input.projectId); + return { presets: store.listFilterPresets(input.projectId) }; + }, + async saveFilterPreset(input) { + await assertFilterScopeExists(input.projectId); + const preset = store.saveFilterPreset({ + projectId: input.projectId, + ...(input.id ? { id: input.id } : {}), + name: input.name, + state: input.state + }); + return { preset, presets: store.listFilterPresets(input.projectId) }; + }, + async deleteFilterPreset(input) { + await assertFilterScopeExists(input.projectId); + return { + presets: store.deleteFilterPreset(input.projectId, input.id) + }; + }, + async reorderFilterPresets(input) { + await assertFilterScopeExists(input.projectId); + return { + presets: store.reorderFilterPresets(input.projectId, input.ids) + }; } }; bb.rpc.register(taskboardRpcContract, handlers); diff --git a/plugins/taskboard/store.ts b/plugins/taskboard/store.ts index a87b501..59bcea9 100644 --- a/plugins/taskboard/store.ts +++ b/plugins/taskboard/store.ts @@ -889,6 +889,10 @@ export function createWorkItemStore(bb: BbPluginApi) { ); return readSavedPreset(id); }, + // Deleting an unknown id is a deliberate no-op, unlike saveFilterPreset + // and reorderFilterPresets which throw. Delete is idempotent, and the + // callers resync from the returned list rather than trusting a local + // delta, so a stale id produces no visible inconsistency. deleteFilterPreset(projectId: string, id: string): FilterPreset[] { bbProjectIdSchema.parse(projectId); return db.transaction(() => { From 3ea2b17f2a5f9ac4c3514c2a0f8446cfd9795321 Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Fri, 21 Aug 2026 17:53:46 +0200 Subject: [PATCH 10/13] Apply Taskboard filter presets from the filter bar --- plugins/taskboard/app.tsx | 193 +++++++++++++++++++++++++++++++++++++- 1 file changed, 192 insertions(+), 1 deletion(-) diff --git a/plugins/taskboard/app.tsx b/plugins/taskboard/app.tsx index 7891528..f2b048e 100644 --- a/plugins/taskboard/app.tsx +++ b/plugins/taskboard/app.tsx @@ -41,6 +41,7 @@ import { DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Icon, type IconName } from '@/components/ui/icon'; @@ -67,6 +68,8 @@ import { boardFilterStateFingerprint, filterStateScopeId, type BoardFilterState, + PRESET_NAME_MAX_LENGTH, + type FilterPreset, type ProjectConfigMutation, type ProjectConfigView, type ProjectCredentialsInteractionResponse, @@ -1625,7 +1628,53 @@ function FilterChip({ ); } +function FilterPresetChip({ + presets, + onApply, + onSaveCurrent +}: { + presets: readonly FilterPreset[]; + onApply: (preset: FilterPreset) => void; + onSaveCurrent: () => void; +}) { + return ( + + + + + + {presets.length === 0 ? ( + No saved presets + ) : ( + presets.map(preset => ( + onApply(preset)} + > + {preset.name} + + )) + )} + + + Save current filters as... + + + + ); +} + function TrackerFilterBar({ + presets, + onApplyPreset, + onSaveCurrentPreset, source, enabledFilters, stateCategories, @@ -1654,6 +1703,9 @@ function TrackerFilterBar({ onViewChange, onClear }: { + presets: readonly FilterPreset[]; + onApplyPreset: (preset: FilterPreset) => void; + onSaveCurrentPreset: () => void; source: SourceFilter; enabledFilters: readonly WorkItemFilterField[]; stateCategories: readonly WorkStateCategory[]; @@ -1707,6 +1759,13 @@ function TrackerFilterBar({ className="tb-filter-bar flex shrink-0 flex-wrap items-center gap-1.5 border-b px-2 py-1.5" >
+ {presets.length > 0 || filtered ? ( + + ) : null} {showSourceFilter ? ( ([]); + const [presetNameDraft, setPresetNameDraft] = useState(null); + const [savingPreset, setSavingPreset] = useState(false); const [source, setSource] = useState( projectId === null ? (initialPreferences?.source ?? ALL_SOURCES) @@ -3041,6 +3103,21 @@ function TrackerList({ }; }, [projectId, rpc, storageScopeId]); + useEffect(() => { + let cancelled = false; + void rpc + .call('listFilterPresets', { projectId: storageScopeId }) + .then(result => { + if (!cancelled) setPresets(result.presets); + }) + .catch(() => { + if (!cancelled) setPresets([]); + }); + return () => { + cancelled = true; + }; + }, [rpc, storageScopeId]); + const loadItems = useCallback(async () => { const requestRevision = ++requestRevisionRef.current; setError(null); @@ -3303,6 +3380,69 @@ function TrackerList({ setQuery(''); setCommittedQuery(''); }; + const applyPreset = useCallback( + (preset: FilterPreset) => { + const next = preset.state; + setSource(projectId === null ? next.source : ALL_SOURCES); + setStateCategories(next.stateCategories); + setStatuses(next.statuses); + setAssignees(next.assignees); + setPriorities(next.priorities); + setExternalProjects(next.externalProjects); + setLabels(next.labels); + setQuery(next.query); + setCommittedQuery(next.query); + setView(next.view); + }, + [projectId] + ); + const saveCurrentPreset = useCallback( + async (name: string) => { + // Guard against a double submit: without this the second call loses + // the duplicate-name check against the row the first one just wrote, + // and pops an error toast over an already-closed dialog. + if (savingPreset) return; + setSavingPreset(true); + try { + const result = await rpc.call('saveFilterPreset', { + projectId: storageScopeId, + name, + state: { + source, + stateCategories, + statuses, + assignees, + priorities, + externalProjects, + labels, + query, + view + } + }); + setPresets(result.presets); + setPresetNameDraft(null); + toast.success(`Saved preset "${result.preset.name}"`); + } catch (error) { + toast.error(describeError(error)); + } finally { + setSavingPreset(false); + } + }, + [ + assignees, + externalProjects, + labels, + priorities, + query, + rpc, + savingPreset, + source, + stateCategories, + statuses, + storageScopeId, + view + ] + ); const moveItemStatus = useCallback( async (item: WorkItem, option: WorkStatusOption) => { const matches = (candidate: WorkItem) => @@ -3346,6 +3486,9 @@ function TrackerList({
setPresetNameDraft('')} source={projectId === null ? source : ALL_SOURCES} enabledFilters={boardSettings.enabledFilters} stateCategories={stateCategories} @@ -3471,7 +3614,55 @@ function TrackerList({
); - return {content}; + return ( + + {content} + { + if (!open) setPresetNameDraft(null); + }} + > + + + Save filter preset + +
{ + event.preventDefault(); + const name = (presetNameDraft ?? '').trim(); + if (name) void saveCurrentPreset(name); + }} + className="flex flex-col gap-3" + > + setPresetNameDraft(event.target.value)} + placeholder="My work" + maxLength={PRESET_NAME_MAX_LENGTH} + aria-label="Preset name" + /> +
+ + +
+
+
+
+
+ ); } function DetailMetadata({ From 30fffdcdd557b126f2201ae1ba0d1b5576ffc334 Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Fri, 21 Aug 2026 18:03:37 +0200 Subject: [PATCH 11/13] Manage Taskboard filter presets in board preferences --- plugins/taskboard/app.tsx | 159 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/plugins/taskboard/app.tsx b/plugins/taskboard/app.tsx index f2b048e..1180c95 100644 --- a/plugins/taskboard/app.tsx +++ b/plugins/taskboard/app.tsx @@ -4661,6 +4661,161 @@ function ProjectBoardSettingsForm({ ); } +function FilterPresetsForm({ projectId }: { projectId: string | null }) { + const rpc = useRpc(); + const [managedPresets, setManagedPresets] = useState< + readonly FilterPreset[] + >([]); + // Serializes rename/delete/reorder: each returns the full authoritative + // list, so a slower response landing after a faster one would clobber it. + const [mutating, setMutating] = useState(false); + + useEffect(() => { + if (!projectId) { + setManagedPresets([]); + return; + } + let cancelled = false; + void rpc + .call('listFilterPresets', { projectId }) + .then(result => { + if (!cancelled) setManagedPresets(result.presets); + }) + .catch(() => { + if (!cancelled) setManagedPresets([]); + }); + return () => { + cancelled = true; + }; + }, [projectId, rpc]); + + const renamePreset = async (preset: FilterPreset, name: string) => { + if (!projectId || mutating) return; + setMutating(true); + try { + const result = await rpc.call('saveFilterPreset', { + projectId, + id: preset.id, + name, + state: preset.state + }); + setManagedPresets(result.presets); + } catch (error) { + toast.error(describeError(error)); + } finally { + setMutating(false); + } + }; + + const removePreset = async (preset: FilterPreset) => { + if (!projectId || mutating) return; + if (!window.confirm(`Delete the preset "${preset.name}"?`)) return; + setMutating(true); + try { + const result = await rpc.call('deleteFilterPreset', { + projectId, + id: preset.id + }); + setManagedPresets(result.presets); + } catch (error) { + toast.error(describeError(error)); + } finally { + setMutating(false); + } + }; + + const movePreset = async (preset: FilterPreset, delta: number) => { + if (!projectId || mutating) return; + const ids = managedPresets.map(candidate => candidate.id); + const from = ids.indexOf(preset.id); + const to = from + delta; + if (from < 0 || to < 0 || to >= ids.length) return; + const reordered = [...ids]; + const [moved] = reordered.splice(from, 1); + reordered.splice(to, 0, moved!); + setMutating(true); + try { + const result = await rpc.call('reorderFilterPresets', { + projectId, + ids: reordered + }); + setManagedPresets(result.presets); + } catch (error) { + toast.error(describeError(error)); + } finally { + setMutating(false); + } + }; + + return ( +
+
+

Filter presets

+

+ Rename, reorder, or delete this project's saved filter + presets. +

+
+ {managedPresets.length === 0 ? ( +

+ Save a preset from the Presets menu in the filter bar. +

+ ) : ( +
    + {managedPresets.map((preset, index) => ( +
  • + { + if (mutating) return; + const name = event.target.value.trim(); + if (name && name !== preset.name) { + void renamePreset(preset, name); + } + }} + /> + + + +
  • + ))} +
+ )} +
+ ); +} + function ManageView({ projectId, projects, @@ -4791,6 +4946,10 @@ function ManageView({ return result.settings; }} /> + ) : (
From 1847f74753ec772f9c6a6cfac5e13a1a613a98e8 Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Sat, 22 Aug 2026 01:12:31 +0200 Subject: [PATCH 12/13] Document Taskboard filter presets --- plugins/taskboard/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/taskboard/README.md b/plugins/taskboard/README.md index ad8ad7e..c6a14bf 100644 --- a/plugins/taskboard/README.md +++ b/plugins/taskboard/README.md @@ -38,6 +38,11 @@ task to an agent without rebuilding context by hand. search text, and List or Kanban choice. They come back on reload and follow you between the board and a thread's Taskboard panel, because they are saved per project rather than per surface. "Clear filters" resets them. +- **Filter presets** — save the current filters under a name and reapply them + from the Presets menu in one click. Rename, reorder, and delete them in + **Manage → Board preferences**. Presets are per project, and none is ever + applied automatically: sticky filters already decide what the board opens + with, so a "default preset" would only compete with them. - **Live task details** — cached summaries keep browsing fast; opening a task fetches its current description, labels, assignee, and comments. - **Pinned beside every chat** — open Taskboard from the thread-header button, From 91037db6add4edd08c313025cb8a1e51896458ad Mon Sep 17 00:00:00 2001 From: Andrii Los Date: Sat, 22 Aug 2026 01:24:09 +0200 Subject: [PATCH 13/13] Add bb taskboard preset commands --- plugins/taskboard/README.md | 6 +- plugins/taskboard/server.ts | 197 ++++++++++++++++++++++++++++++++++-- 2 files changed, 194 insertions(+), 9 deletions(-) diff --git a/plugins/taskboard/README.md b/plugins/taskboard/README.md index c6a14bf..f1f7897 100644 --- a/plugins/taskboard/README.md +++ b/plugins/taskboard/README.md @@ -154,10 +154,14 @@ bb taskboard status [--project ] [--json] bb taskboard config [--project ] [--source linear|github|jira] [provider fields] [--json] bb taskboard credentials [--project ] [--json] bb taskboard refresh [linear|github|jira] [--project ] [--json] -bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--cached] [--json] +bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--preset ] [--cached] [--json] bb taskboard show [--project ] [--json] bb taskboard transitions [--project ] [--json] bb taskboard move --status [--project ] [--json] +bb taskboard presets list [--project ] [--json] +bb taskboard presets save --from-state [--project ] [--json] +bb taskboard presets rename [--project ] [--json] +bb taskboard presets delete [--project ] [--json] ``` An explicit source must match the tracker selected for that project. Taskboard diff --git a/plugins/taskboard/server.ts b/plugins/taskboard/server.ts index 6109c8e..e448b1c 100644 --- a/plugins/taskboard/server.ts +++ b/plugins/taskboard/server.ts @@ -1,9 +1,12 @@ import type { BbPluginApi, PluginRpcHandlers } from '@get-bb/plugin-sdk'; import { ACROSS_PROJECTS_SCOPE_ID, + ALL_SOURCES_FILTER, bbProjectIdSchema, + boardFilterStateSchema, formatWorkItemContext, issueDraftRecordSchema, + normalizePresetName, projectConfigMutationSchema, projectCredentialsInteractionResponseSchema, projectSourceConfigSchema, @@ -13,6 +16,7 @@ import { taskboardRpcContract, type CreateIssueContext, type CreateIssueInput, + type FilterPreset, type IssueDraftRecord, type ProjectConfigMutation, type ProjectConfigView, @@ -26,6 +30,7 @@ import { type WorkStatusOption, type WorkSourceStatus } from './contract.js'; +import { filterWorkItemsByAttributes } from './browse.js'; import { buildIssueDraftPrompt, parseIssueDraftOutput, @@ -155,13 +160,25 @@ interface ParsedCliArguments { jiraEmail: string | undefined; jiraJql: string | undefined; statusId: string | undefined; + preset: string | undefined; + fromState: string | undefined; json: boolean; cached: boolean; } const CLI_OPTIONS_BY_COMMAND = new Map>([ ['status', new Set(['--project', '--json'])], - ['list', new Set(['--project', '--source', '--query', '--cached', '--json'])], + [ + 'list', + new Set([ + '--project', + '--source', + '--query', + '--preset', + '--cached', + '--json' + ]) + ], ['show', new Set(['--project', '--json'])], ['refresh', new Set(['--project', '--json'])], ['transitions', new Set(['--project', '--json'])], @@ -178,7 +195,8 @@ const CLI_OPTIONS_BY_COMMAND = new Map>([ '--json' ]) ], - ['credentials', new Set(['--project', '--json'])] + ['credentials', new Set(['--project', '--json'])], + ['presets', new Set(['--project', '--from-state', '--json'])] ]); function parseCliArguments( @@ -198,6 +216,8 @@ function parseCliArguments( let jiraEmail: string | undefined; let jiraJql: string | undefined; let statusId: string | undefined; + let preset: string | undefined; + let fromState: string | undefined; let json = false; let cached = false; @@ -251,6 +271,12 @@ function parseCliArguments( } else if (argument === '--status') { statusId = valueAfter(argument, index); index += 1; + } else if (argument === '--preset') { + preset = valueAfter(argument, index); + index += 1; + } else if (argument === '--from-state') { + fromState = valueAfter(argument, index); + index += 1; } else if (argument === '--json') { json = true; } else if (argument === '--cached') { @@ -268,6 +294,8 @@ function parseCliArguments( jiraEmail, jiraJql, statusId, + preset, + fromState, json, cached }; @@ -599,6 +627,24 @@ export default async function plugin(bb: BbPluginApi) { await assertProjectExists(scopeId); } + const resolvePresetByName = ( + projectId: string, + name: string + ): FilterPreset => { + const presets = store.listFilterPresets(projectId); + const normalized = normalizePresetName(name); + const match = presets.find( + candidate => normalizePresetName(candidate.name) === normalized + ); + if (match) return match; + const available = presets.map(candidate => candidate.name).join(', '); + throw new Error( + available + ? `Unknown filter preset "${name}". Available: ${available}` + : `Unknown filter preset "${name}". This project has no presets.` + ); + }; + async function fallbackGithubRepos(projectId: string): Promise { const project = (await liveProjects()).find( entry => entry.id === projectId @@ -1863,7 +1909,9 @@ export default async function plugin(bb: BbPluginApi) { name: 'list', summary: 'List cached project work, refreshing first by default', usage: - 'bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--cached] [--json]' + 'bb taskboard list [--project ] ' + + '[--source linear|github|jira] [--query ] ' + + '[--preset ] [--cached] [--json]' }, { name: 'show', @@ -1899,6 +1947,18 @@ export default async function plugin(bb: BbPluginApi) { name: 'credentials', summary: 'Open a secure form for project connector credentials', usage: 'bb taskboard credentials [--project ] [--json]' + }, + { + name: 'presets', + summary: 'List, save, rename, or delete project filter presets', + usage: + 'bb taskboard presets list [--project ] [--json]\n' + + 'bb taskboard presets save --from-state ' + + '[--project ] [--json]\n' + + 'bb taskboard presets rename ' + + '[--project ] [--json]\n' + + 'bb taskboard presets delete ' + + '[--project ] [--json]' } ], async run(argv, ctx) { @@ -1994,7 +2054,9 @@ export default async function plugin(bb: BbPluginApi) { if (command === 'list') { if (args.positionals.length > 0) { throw new Error( - 'Usage: bb taskboard list [--project ] [--source linear|github|jira] [--query ] [--cached] [--json]' + 'Usage: bb taskboard list [--project ] ' + + '[--source linear|github|jira] [--query ] ' + + '[--preset ] [--cached] [--json]' ); } const sourceValue = args.source; @@ -2004,8 +2066,18 @@ export default async function plugin(bb: BbPluginApi) { if (parsedSource && !parsedSource.success) { throw new Error('Source must be linear, github, or jira'); } - const source = parsedSource?.data; const project = await requireProject(); + // Explicit --source/--query flags beat a --preset's saved values; + // a preset source of "all" means the preset applies no filter. + const preset = args.preset + ? resolvePresetByName(project.id, args.preset) + : undefined; + const presetSource = + preset && preset.state.source !== ALL_SOURCES_FILTER + ? preset.state.source + : undefined; + const source = parsedSource?.data ?? presetSource; + const query = args.query ?? preset?.state.query; if (source) { await assertSelectedSourceAfterMutations(project.id, source); } @@ -2013,14 +2085,26 @@ export default async function plugin(bb: BbPluginApi) { const items = store.list({ projectId: project.id, ...(source ? { source } : {}), - ...(args.query ? { query: args.query } : {}), + ...(query ? { query } : {}), + ...(preset + ? { stateCategories: preset.state.stateCategories } + : {}), limit: 200 }); + const narrowedItems = preset + ? filterWorkItemsByAttributes(items, { + statuses: preset.state.statuses, + assignees: preset.state.assignees, + priorities: preset.state.priorities, + projects: preset.state.externalProjects, + labels: preset.state.labels + }) + : items; return { exitCode: 0, stdout: args.json - ? JSON.stringify({ items }, null, 2) - : items + ? JSON.stringify({ items: narrowedItems }, null, 2) + : narrowedItems .map( item => `${item.bbProjectId}\t${sourceName(item.source)}\t${item.key}\t${item.status}\t${item.assignee ?? '-'}\t${item.title}` @@ -2223,6 +2307,103 @@ export default async function plugin(bb: BbPluginApi) { : formatCredentialStatus(config) }; } + if (command === 'presets') { + const verb = args.positionals[0] ?? 'list'; + const rest = args.positionals.slice(1); + const project = await requireProject(); + + if (verb === 'list') { + if (rest.length > 0) { + throw new Error( + 'Usage: bb taskboard presets list ' + + '[--project ] [--json]' + ); + } + const presets = store.listFilterPresets(project.id); + return { + exitCode: 0, + stdout: args.json + ? JSON.stringify({ presets }, null, 2) + : presets.length > 0 + ? presets.map(item => item.name).join('\n') + : 'This project has no filter presets.' + }; + } + + if (verb === 'save') { + if (rest.length !== 1 || !args.fromState) { + throw new Error( + 'Usage: bb taskboard presets save ' + + '--from-state [--project ] [--json]' + ); + } + let fromStateJson: unknown; + try { + fromStateJson = JSON.parse(args.fromState); + } catch { + throw new Error('--from-state must be valid JSON'); + } + const parsedState = + boardFilterStateSchema.safeParse(fromStateJson); + if (!parsedState.success) { + throw new Error('--from-state is not a valid filter state'); + } + const preset = store.saveFilterPreset({ + projectId: project.id, + name: rest[0]!, + state: parsedState.data + }); + return { + exitCode: 0, + stdout: args.json + ? JSON.stringify({ preset }, null, 2) + : `Saved preset "${preset.name}"` + }; + } + + if (verb === 'rename') { + if (rest.length !== 2) { + throw new Error( + 'Usage: bb taskboard presets rename ' + + '[--project ] [--json]' + ); + } + const existing = resolvePresetByName(project.id, rest[0]!); + const preset = store.saveFilterPreset({ + projectId: project.id, + id: existing.id, + name: rest[1]!, + state: existing.state + }); + return { + exitCode: 0, + stdout: args.json + ? JSON.stringify({ preset }, null, 2) + : `Renamed preset "${existing.name}" to "${preset.name}"` + }; + } + + if (verb === 'delete') { + if (rest.length !== 1) { + throw new Error( + 'Usage: bb taskboard presets delete ' + + '[--project ] [--json]' + ); + } + const existing = resolvePresetByName(project.id, rest[0]!); + const presets = store.deleteFilterPreset(project.id, existing.id); + return { + exitCode: 0, + stdout: args.json + ? JSON.stringify({ presets }, null, 2) + : `Deleted preset "${existing.name}"` + }; + } + + throw new Error( + 'Usage: bb taskboard presets ...' + ); + } throw new Error('Unknown bb taskboard command'); } catch (error) { return { exitCode: 1, stderr: `${errorMessage(error)}\n` };