Files
kandev/apps/web/lib/local-storage.ts
T
José Almeida f6f45b5777 feat: add filter/group/sort and saved views to task sidebar (#644)
* feat(web): add sidebar view types and apply-view engine

Introduces the typed representation of a sidebar view (filter clauses,
sort spec, group key) and the pure engine that applies a view to a list
of tasks to produce a filtered, sorted, grouped result.

* feat(web): add built-in sidebar view presets

Provides the four shipped presets (All tasks, No PR reviews, Active,
Archived). The 'No PR reviews' preset filters out PR-watcher tasks that
would otherwise crowd the sidebar.

* feat(web): persist sidebar views in ui store

Adds sidebar view state to the ui slice with draft semantics for saved
views, localStorage persistence of active view + user-created views, and
root store wiring.

* feat(web): add filter dimension registry and clause editor

Registry of filterable task dimensions (status, workspace, workflow, task
type, etc.) plus the primitive UI pieces — clause editor, sort picker,
group picker, and a hook that supplies operator and value options for a
selected dimension.

* feat(web): add sidebar filter bar, popover, and view manager

Composes the filter primitives into the user-facing surfaces: the top
filter bar, the popover that hosts clause editing and sort/group
selection, the chips that switch between views, and the manager for
saving, renaming, and deleting user views.

* refactor(web): extract NewTaskButton component

Pulls the Create Task button and its command-palette registration into a
standalone memoized component in preparation for reuse across sidebar
surfaces.

* feat(web): integrate filter/view system into task sidebar

Wires the filter bar, view chips, and apply-view engine into the task
sidebar (desktop and mobile sheet). Updates task-switcher to consume the
grouped list from the engine and adds mock data for tests and previews.

* test(web): add e2e coverage for sidebar filter/view system

Page object for the filter popover plus Playwright specs covering built-in
presets, clause editing, sort/group, saved-view draft semantics, and
persistence across reloads.

* refactor(web): trim sidebar built-in views to a single seeded default

Drop the "No PR reviews", "Active", and "Archived" built-in view presets
and the isBuiltIn protection flag. The sidebar now seeds a single "All
tasks" view on first load; users can rename, overwrite, or delete it like
any other view. Deletion is guarded against removing the last remaining
view so the user can never lock themselves out.

* fix(web): humanize executor type labels in sidebar filter and grouping

The executor-type filter dropdown and group-by label were rendering raw
backend identifiers like "worktree" and "local_docker". Route both
through the existing getExecutorLabel() helper so the sidebar shows
"Worktree" and "Local Docker" like the rest of the app.

* feat(web): replace "Has PR" filter with "Is PR review"

The old dimension matched any task with an associated PR, including
tasks a user manually linked. The new dimension isolates tasks created
by the GitHub PR-review watcher specifically, which is what the filter
is useful for — hiding a wall of auto-generated review tasks.

The flag is derived from the review_watch_id metadata key the backend
already stamps onto watcher-created tasks, threaded through the kanban
snapshot and into the sidebar TaskSwitcherItem. A small migration drops
unknown dimensions (including the old "hasPR") from stored views so the
popover does not crash when reading legacy data.

* fix(web): tighten sidebar filter popover layout

Reduce the popover width (w-96 → w-[22rem]), trim section padding
(p-3 → p-2.5) and label margins (mb-2 → mb-1.5), and drop the
"No filters applied." placeholder. Extract a shared SectionLabel to
keep the three section headers visually aligned.

* feat(web): show workflow step color dot in sidebar filter dropdown

Surface the step color on the workflow-step filter's value dropdown so
each option renders with the same coloured circle used elsewhere (task
switcher, kanban column, command panel). Adds an optional color field
on filter value options; only workflow-step options populate it today.

* feat(web): replace cramped in/not-in filter editor with a multiselect dropdown

The previous "in/not in" value input rendered selected values as tiny
10px pills inside a fixed-height row that overflowed and truncated as
the list of options grew. Replace it with a combobox-style multiselect
(Popover + Command) that shows a compact summary ("N selected" or the
single label with its color dot) and opens a searchable, scrollable
list with check indicators on click.

* fix(web): show workflow and step names (not UUIDs) in sidebar grouping

Group-by Workflow and Workflow Step rendered raw UUID strings as group
headers because `TaskSwitcherItem` did not carry the resolved names.
Pass `workflowName` and `workflowStepTitle` through `toSidebarItem` and
prefer them in the group extractors.

* fix(web): drop redundant true/false selector on boolean sidebar filters

Boolean dimensions (PR review, Archived, Has diff) previously exposed
both an operator (is / is not) and a value selector (true / false),
which gives four user inputs for two real states. Hide the value input
for boolean clauses and always store `true`; negation is expressed via
the operator. Rename "Is PR review" to "PR review" since the dimension
label no longer needs to read as a question.

* refactor(web): drop Sort by Repository and relabel State to Status

Sorting by repository duplicates what Group by Repository already
offers, and "State" leaks a backend term into the picker. Remove the
repository sort key (comparator, type, picker option) and rename the
state option to "Status". Stored views referencing the removed key are
migrated back to `state` at load time so the popover does not crash.

* fix(web): tighten sidebar filter popover section spacing

The FILTERS, SORT, and GROUP BY section labels each sat above their
controls with both padded containers (p-2.5) and a 6px bottom margin,
leaving visible dead space above each header. Reduce container padding
to p-2 and label margin to mb-1 so sections read as a compact stack.

* chore(web): apply prettier formatting drift in sidebar-filter area

* revert(web): undo store.ts prettier reformat to stay under max-lines cap

The packed single-line UIA declaration in store.ts is load-bearing for
the 600-line ESLint cap: the previous chore commit unpacked it to 9
lines and pushed the file to 609 lines, turning the max-lines warning
into a lint failure. Restore the packed layout (the /* prettier-ignore */
comment is there for exactly this reason).

* refactor(web): label boolean filter ops as Show/Hide instead of is/is not

* fix(web): pull section labels flush to sidebar filter popover borders

* refactor(web): inline rename and save-as into sidebar view header row

* refactor(web): split ViewHeaderRow into mode, display, and action subcomponents

* feat(backend): persist sidebar views in user settings

Adds a SidebarViews field to UserSettings (stored in the existing
users.settings JSON blob) plus validation in the service layer:
views are capped at 50, ids and names must be non-empty and unique.

* feat(web): hydrate and sync sidebar views to backend

Moves the source of truth for saved sidebar views from localStorage
to the user settings endpoint. Views hydrate from SSR on first load;
create/rename/overwrite/delete/duplicate/toggle-collapsed all PATCH
the backend with optimistic updates and revert-on-failure. The
active view id and draft remain per-device in localStorage. A
sync bridge surfaces PATCH errors as toasts and runs a one-time
migration to upload any pre-existing local views.

As a side effect, localStorage writes and PATCH side effects are
moved out of immer producers to avoid proxy-serialization risk.

* chore(web): apply prettier to store.ts sidebar actions block

* fix(web): run migrateView on SSR-hydrated sidebar views

Defensively drop clauses with unknown dimensions and reset stale sort
keys when views come from the backend, matching the localStorage load
path. Also notes the default-state module and sidebar views in the
store structure docs.

* docs: note default-state module and sidebar views in store structure

* fix(web): type sidebar view storage wrappers properly

Drop the as-unknown-as-SidebarView and as-never casts at call sites by
making the local-storage getters/setters accept the domain type and do
the JsonValue cast once inside the wrapper.

* refactor(web): stop PATCHing on sidebar group collapse toggles

collapsedGroups is per-device visual state; routing it through
mutateViews made every expand/collapse click fire a user-settings
PATCH. Keep it local (in-memory + localStorage cache); the next real
view mutation picks up whatever state is current.

* fix(web): skip sidebar view migration when settings hydrated from server

After SSR hydration the in-memory views are already the backend's
truth, so migrating re-POSTs the same data every page load. Gate the
one-shot migration on userSettings.loaded — only run if the server
didn't answer and the slice fell back to localStorage.
2026-04-20 13:44:24 +01:00

740 lines
23 KiB
TypeScript

type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
// Session Storage helpers (cleared when browser tab closes)
export function getSessionStorage<T extends JsonValue>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = window.sessionStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function setSessionStorage<T extends JsonValue>(key: string, value: T): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(key, JSON.stringify(value));
} catch {
// Ignore write failures (storage full, blocked, etc.)
}
}
// Local Storage helpers (persists across browser sessions)
export function getLocalStorage<T extends JsonValue>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export function setLocalStorage<T extends JsonValue>(key: string, value: T): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Ignore write failures (storage full, blocked, etc.)
}
}
export function removeSessionStorage(key: string): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(key);
} catch {
// Ignore removal failures.
}
}
export function removeLocalStorage(key: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(key);
} catch {
// Ignore removal failures.
}
}
// Internal storage keys for kanban preview (not exported - encapsulated)
const KANBAN_PREVIEW_KEYS = {
OPEN: "kandev.kanban.preview.open",
WIDTH: "kandev.kanban.preview.width",
SELECTED_TASK: "kandev.kanban.preview.selectedTask",
} as const;
// Kanban preview state type
export interface KanbanPreviewState {
isOpen: boolean;
previewWidthPx: number;
selectedTaskId: string | null;
}
/**
* Get the kanban preview state from localStorage
* @param defaults - Default values to use if not found in localStorage
* @returns The kanban preview state
*/
export function getKanbanPreviewState(defaults: KanbanPreviewState): KanbanPreviewState {
return {
isOpen: getLocalStorage(KANBAN_PREVIEW_KEYS.OPEN, defaults.isOpen),
previewWidthPx: getLocalStorage(KANBAN_PREVIEW_KEYS.WIDTH, defaults.previewWidthPx),
selectedTaskId: getLocalStorage(KANBAN_PREVIEW_KEYS.SELECTED_TASK, defaults.selectedTaskId),
};
}
/**
* Set the kanban preview state in localStorage
* @param state - Partial state to update (only provided fields are updated)
*/
export function setKanbanPreviewState(state: Partial<KanbanPreviewState>): void {
if (state.isOpen !== undefined) {
setLocalStorage(KANBAN_PREVIEW_KEYS.OPEN, state.isOpen);
}
if (state.previewWidthPx !== undefined) {
setLocalStorage(KANBAN_PREVIEW_KEYS.WIDTH, state.previewWidthPx);
}
if (state.selectedTaskId !== undefined) {
if (state.selectedTaskId === null) {
removeLocalStorage(KANBAN_PREVIEW_KEYS.SELECTED_TASK);
} else {
setLocalStorage(KANBAN_PREVIEW_KEYS.SELECTED_TASK, state.selectedTaskId);
}
}
}
// Internal storage key for plan notifications (not exported - encapsulated)
const PLAN_NOTIFICATION_KEY = "kandev.plan.lastSeenByTask";
/**
* Plan notification state - tracks when user last viewed each task's plan
* Key is taskId, value is the plan's updated_at timestamp when last viewed
*/
export type PlanNotificationState = Record<string, string | null>;
/**
* Get the plan notification state from localStorage
* @returns Record of taskId -> last seen plan update timestamp
*/
export function getPlanNotificationState(): PlanNotificationState {
return getLocalStorage(PLAN_NOTIFICATION_KEY, {} as PlanNotificationState);
}
/**
* Set the last seen timestamp for a specific task's plan
* @param taskId - The task ID
* @param timestamp - The plan's updated_at timestamp when viewed (or null to clear)
*/
export function setPlanLastSeen(taskId: string, timestamp: string | null): void {
const state = getPlanNotificationState();
if (timestamp === null) {
delete state[taskId];
} else {
state[taskId] = timestamp;
}
setLocalStorage(PLAN_NOTIFICATION_KEY, state);
}
/**
* Get the last seen timestamp for a specific task's plan
* @param taskId - The task ID
* @returns The last seen timestamp, or null if never viewed
*/
export function getPlanLastSeen(taskId: string): string | null {
const state = getPlanNotificationState();
return state[taskId] ?? null;
}
// Internal storage key for center panel tab (uses sessionStorage)
const CENTER_PANEL_TAB_KEY = "kandev.centerPanel.tab";
/**
* Get the saved center panel tab from sessionStorage
* @param fallback - Default tab if not found
* @returns The saved tab id
*/
export function getCenterPanelTab(fallback: string): string {
if (typeof window === "undefined") return fallback;
try {
const raw = window.sessionStorage.getItem(CENTER_PANEL_TAB_KEY);
if (!raw) return fallback;
return JSON.parse(raw) as string;
} catch {
return fallback;
}
}
/**
* Save the center panel tab to sessionStorage
* @param tab - The tab id to save
*/
export function setCenterPanelTab(tab: string): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(CENTER_PANEL_TAB_KEY, JSON.stringify(tab));
} catch {
// Ignore write failures
}
}
// Internal storage keys for files panel (uses sessionStorage for per-tab persistence)
const FILES_PANEL_KEYS = {
TAB: "kandev.filesPanel.tab",
USER_SELECTED: "kandev.filesPanel.userSelected",
EXPANDED: "kandev.filesPanel.expanded",
SCROLL: "kandev.filesPanel.scroll",
} as const;
/**
* Get the saved files panel tab for a session
* @param sessionId - The session ID
* @param fallback - Default tab if not found
* @returns The saved tab ('diff' or 'files')
*/
export function getFilesPanelTab(sessionId: string, fallback: "diff" | "files"): "diff" | "files" {
if (typeof window === "undefined") return fallback;
try {
const key = `${FILES_PANEL_KEYS.TAB}.${sessionId}`;
const raw = window.sessionStorage.getItem(key);
if (!raw) return fallback;
const value = JSON.parse(raw) as string;
return value === "diff" || value === "files" ? value : fallback;
} catch {
return fallback;
}
}
/**
* Save the files panel tab for a session
* @param sessionId - The session ID
* @param tab - The tab to save ('diff' or 'files')
*/
export function setFilesPanelTab(sessionId: string, tab: "diff" | "files"): void {
if (typeof window === "undefined") return;
try {
const key = `${FILES_PANEL_KEYS.TAB}.${sessionId}`;
window.sessionStorage.setItem(key, JSON.stringify(tab));
} catch {
// Ignore write failures
}
}
/**
* Check if user has explicitly selected a tab for this session
* @param sessionId - The session ID
* @returns true if user has made a selection
*/
export function hasUserSelectedFilesPanelTab(sessionId: string): boolean {
if (typeof window === "undefined") return false;
try {
const key = `${FILES_PANEL_KEYS.USER_SELECTED}.${sessionId}`;
return window.sessionStorage.getItem(key) === "true";
} catch {
return false;
}
}
/**
* Mark that user has explicitly selected a tab for this session
* @param sessionId - The session ID
*/
export function setUserSelectedFilesPanelTab(sessionId: string): void {
if (typeof window === "undefined") return;
try {
const key = `${FILES_PANEL_KEYS.USER_SELECTED}.${sessionId}`;
window.sessionStorage.setItem(key, "true");
} catch {
// Ignore write failures
}
}
/**
* Get the saved expanded paths for file browser
* @param sessionId - The session ID
* @returns Array of expanded folder paths
*/
export function getFilesPanelExpandedPaths(sessionId: string): string[] {
if (typeof window === "undefined") return [];
try {
const key = `${FILES_PANEL_KEYS.EXPANDED}.${sessionId}`;
const raw = window.sessionStorage.getItem(key);
if (!raw) return [];
return JSON.parse(raw) as string[];
} catch {
return [];
}
}
/**
* Save the expanded paths for file browser
* @param sessionId - The session ID
* @param paths - Array of expanded folder paths
*/
export function setFilesPanelExpandedPaths(sessionId: string, paths: string[]): void {
if (typeof window === "undefined") return;
try {
const key = `${FILES_PANEL_KEYS.EXPANDED}.${sessionId}`;
window.sessionStorage.setItem(key, JSON.stringify(paths));
} catch {
// Ignore write failures
}
}
/**
* Get the saved scroll position for file browser
* @param sessionId - The session ID
* @returns The scroll position in pixels
*/
export function getFilesPanelScrollPosition(sessionId: string): number {
if (typeof window === "undefined") return 0;
try {
const key = `${FILES_PANEL_KEYS.SCROLL}.${sessionId}`;
const raw = window.sessionStorage.getItem(key);
if (!raw) return 0;
return JSON.parse(raw) as number;
} catch {
return 0;
}
}
/**
* Save the scroll position for file browser
* @param sessionId - The session ID
* @param position - The scroll position in pixels
*/
export function setFilesPanelScrollPosition(sessionId: string, position: number): void {
if (typeof window === "undefined") return;
try {
const key = `${FILES_PANEL_KEYS.SCROLL}.${sessionId}`;
window.sessionStorage.setItem(key, JSON.stringify(position));
} catch {
// Ignore write failures
}
}
// --- Dockview per-session layout (sessionStorage) ---
const DOCKVIEW_SESSION_LAYOUT_PREFIX = "kandev.dockview.layout.";
/**
* Get the saved dockview layout for a session
* @param sessionId - The session ID
* @returns The serialized layout object, or null if not found
*/
export function getSessionLayout(sessionId: string): object | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(`${DOCKVIEW_SESSION_LAYOUT_PREFIX}${sessionId}`);
if (!raw) return null;
return JSON.parse(raw) as object;
} catch {
return null;
}
}
/**
* Save the dockview layout for a session
* @param sessionId - The session ID
* @param layout - The serialized layout object from api.toJSON()
*/
export function setSessionLayout(sessionId: string, layout: object): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(
`${DOCKVIEW_SESSION_LAYOUT_PREFIX}${sessionId}`,
JSON.stringify(layout),
);
} catch {
// Ignore write failures (storage full, blocked, etc.)
}
}
// --- Dockview per-session maximize state (sessionStorage) ---
const DOCKVIEW_SESSION_MAXIMIZE_PREFIX = "kandev.dockview.maximize.";
export type SessionMaximizeState = {
/** The pre-maximize (normal) layout to restore on exit-maximize. */
preMaximizeLayout: object;
/** Native dockview JSON (api.toJSON()) for the maximized layout. */
maximizedDockviewJson: object;
};
function isSessionMaximizeState(value: unknown): value is SessionMaximizeState {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return (
typeof v.preMaximizeLayout === "object" &&
v.preMaximizeLayout !== null &&
typeof v.maximizedDockviewJson === "object" &&
v.maximizedDockviewJson !== null
);
}
export function getSessionMaximizeState(sessionId: string): SessionMaximizeState | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(`${DOCKVIEW_SESSION_MAXIMIZE_PREFIX}${sessionId}`);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return isSessionMaximizeState(parsed) ? parsed : null;
} catch {
return null;
}
}
export function setSessionMaximizeState(sessionId: string, state: SessionMaximizeState): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(
`${DOCKVIEW_SESSION_MAXIMIZE_PREFIX}${sessionId}`,
JSON.stringify(state),
);
} catch {
// Ignore write failures
}
}
export function removeSessionMaximizeState(sessionId: string): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(`${DOCKVIEW_SESSION_MAXIMIZE_PREFIX}${sessionId}`);
} catch {
// Ignore
}
}
// PR panel "offered" flag — tracks whether the auto-show PR panel was offered
// for a session. If offered and then closed by the user, we respect the dismissal.
const PR_PANEL_OFFERED_PREFIX = "kandev.pr-panel-offered.";
export function wasPRPanelOffered(sessionId: string): boolean {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(`${PR_PANEL_OFFERED_PREFIX}${sessionId}`) === "1";
} catch {
return false;
}
}
export function markPRPanelOffered(sessionId: string): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(`${PR_PANEL_OFFERED_PREFIX}${sessionId}`, "1");
} catch {
// Ignore write failures
}
}
// Internal storage keys for open file tabs
const OPEN_FILES_KEY = "kandev.openFiles";
const ACTIVE_TAB_KEY = "kandev.activeTab";
/**
* Minimal tab info stored in sessionStorage (no content - reloaded on restore).
* `pinned` distinguishes user-pinned tabs (restored always) from the single
* preview tab (restored as preview).
*/
export interface StoredFileTab {
path: string;
name: string;
markdownPreview?: boolean;
pinned?: boolean;
}
/**
* Get the saved open file tabs for a session.
*
* Legacy records (written before the preview-tab feature) have no `pinned`
* field. We treat them as pinned so the user's previously-open files don't
* suddenly collapse to a single preview after upgrading.
*/
export function getOpenFileTabs(sessionId: string): StoredFileTab[] {
if (typeof window === "undefined") return [];
try {
const key = `${OPEN_FILES_KEY}.${sessionId}`;
const raw = window.sessionStorage.getItem(key);
if (!raw) return [];
const parsed = JSON.parse(raw) as StoredFileTab[];
if (!Array.isArray(parsed)) return [];
// At most one tab can be the preview; keep the last one flagged preview and
// treat every other record as pinned. Records with `pinned: undefined` are
// legacy → pin them so we don't lose them.
let previewSeen = false;
const normalized: StoredFileTab[] = [];
for (let i = parsed.length - 1; i >= 0; i--) {
const t = parsed[i];
if (!t) continue;
const isPinned = t.pinned === true || t.pinned === undefined;
if (isPinned) {
normalized.unshift({ ...t, pinned: true });
} else if (!previewSeen) {
previewSeen = true;
normalized.unshift({ ...t, pinned: false });
}
}
return normalized;
} catch {
return [];
}
}
export function setOpenFileTabs(sessionId: string, tabs: StoredFileTab[]): void {
if (typeof window === "undefined") return;
try {
const key = `${OPEN_FILES_KEY}.${sessionId}`;
window.sessionStorage.setItem(key, JSON.stringify(tabs));
} catch {
// Ignore write failures
}
}
/**
* Get the saved active tab for a session
* @param sessionId - The session ID
* @param fallback - Default tab if not found
* @returns The saved active tab id (e.g., 'chat', 'plan', 'file:/path/to/file')
*/
export function getActiveTabForSession(sessionId: string, fallback: string): string {
if (typeof window === "undefined") return fallback;
try {
const key = `${ACTIVE_TAB_KEY}.${sessionId}`;
const raw = window.sessionStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as string;
} catch {
return fallback;
}
}
/**
* Save the active tab for a session
* @param sessionId - The session ID
* @param tabId - The tab id to save
*/
export function setActiveTabForSession(sessionId: string, tabId: string): void {
if (typeof window === "undefined") return;
try {
const key = `${ACTIVE_TAB_KEY}.${sessionId}`;
window.sessionStorage.setItem(key, JSON.stringify(tabId));
} catch {
// Ignore write failures
}
}
// --- Chat draft persistence (sessionStorage, per task) ---
const CHAT_DRAFT_TEXT_KEY = "kandev.chatDraft.text";
const CHAT_DRAFT_CONTENT_KEY = "kandev.chatDraft.content";
const CHAT_DRAFT_ATTACHMENTS_KEY = "kandev.chatDraft.attachments";
const CHAT_INPUT_HEIGHT_KEY = "kandev.chatInput.height";
/** Stored attachment — same as FileAttachment but without `preview` (reconstructed on load) */
type StoredFileAttachment = {
id: string;
data: string;
mimeType: string;
fileName: string;
size: number;
isImage: boolean;
};
export function getChatDraftText(sessionId: string): string {
return getSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`, "");
}
export function setChatDraftText(sessionId: string, text: string): void {
if (text === "") {
removeSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`);
} else {
setSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`, text);
}
}
/** TipTap editor JSON — preserves rich content (mentions, code blocks, etc.) */
export function getChatDraftContent(sessionId: string): unknown {
return getSessionStorage<JsonValue | null>(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`, null);
}
export function setChatDraftContent(sessionId: string, content: unknown): void {
if (!content) {
removeSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`);
} else {
setSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`, content as JsonValue);
}
}
export function getChatDraftAttachments(sessionId: string): StoredFileAttachment[] {
return getSessionStorage<StoredFileAttachment[]>(
`${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`,
[],
);
}
export function setChatDraftAttachments(
sessionId: string,
attachments: Array<{
id: string;
data: string;
mimeType: string;
fileName: string;
size: number;
isImage: boolean;
preview?: string;
}>,
): void {
if (attachments.length === 0) {
removeSessionStorage(`${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`);
} else {
// Strip `preview` to halve storage cost — reconstructed on load for images
const stored: StoredFileAttachment[] = attachments.map(
({ id, data, mimeType, fileName, size, isImage }) => ({
id,
data,
mimeType,
fileName,
size,
isImage,
}),
);
setSessionStorage(`${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`, stored);
}
}
/**
* Reconstruct the `preview` data URL from stored attachment data (images only).
*/
export function restoreAttachmentPreview(
att: StoredFileAttachment,
): StoredFileAttachment & { preview?: string } {
if (att.isImage) {
return { ...att, preview: `data:${att.mimeType};base64,${att.data}` };
}
return att;
}
export function getChatInputHeight(sessionId: string): number | null {
return getSessionStorage<number | null>(`${CHAT_INPUT_HEIGHT_KEY}.${sessionId}`, null);
}
export function setChatInputHeight(sessionId: string, height: number): void {
setSessionStorage(`${CHAT_INPUT_HEIGHT_KEY}.${sessionId}`, height);
}
// --- Task storage cleanup ---
/**
* Remove all session-scoped storage for a deleted task.
* Call from task.deleted handler before the task is removed from state.
*/
export function cleanupTaskStorage(taskId: string, sessionIds: string[]): void {
// Plan notification (localStorage, keyed per task inside a Record)
setPlanLastSeen(taskId, null);
// Session-keyed storage — clean all sessions belonging to the task
for (const sessionId of sessionIds) {
removeSessionMaximizeState(sessionId);
removeSessionStorage(`${PR_PANEL_OFFERED_PREFIX}${sessionId}`);
removeSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`);
removeSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`);
removeSessionStorage(`${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`);
removeSessionStorage(`${CHAT_INPUT_HEIGHT_KEY}.${sessionId}`);
removeSessionStorage(`${FILES_PANEL_KEYS.TAB}.${sessionId}`);
removeSessionStorage(`${FILES_PANEL_KEYS.USER_SELECTED}.${sessionId}`);
removeSessionStorage(`${FILES_PANEL_KEYS.EXPANDED}.${sessionId}`);
removeSessionStorage(`${FILES_PANEL_KEYS.SCROLL}.${sessionId}`);
removeSessionStorage(`${DOCKVIEW_SESSION_LAYOUT_PREFIX}${sessionId}`);
removeSessionStorage(`${OPEN_FILES_KEY}.${sessionId}`);
removeSessionStorage(`${ACTIVE_TAB_KEY}.${sessionId}`);
removeSessionStorage(`kandev.contextFiles.${sessionId}`);
removeSessionStorage(`kandev.comments.${sessionId}`);
}
}
// --- Sidebar filter views (localStorage, global) ---
const SIDEBAR_VIEWS_KEY = "kandev.sidebar.views";
const SIDEBAR_ACTIVE_VIEW_KEY = "kandev.sidebar.activeViewId";
const SIDEBAR_DRAFT_KEY = "kandev.sidebar.draft";
// The SidebarView / SidebarViewDraft types aren't structurally assignable to
// JsonValue (the filter clause value is `unknown`), so these wrappers take the
// domain type and do the cast once here — keeps call sites type-safe.
export function getStoredSidebarUserViews<T>(fallback: T): T {
return getLocalStorage(SIDEBAR_VIEWS_KEY, fallback as unknown as JsonValue) as unknown as T;
}
export function setStoredSidebarUserViews<T>(views: T): void {
setLocalStorage(SIDEBAR_VIEWS_KEY, views as unknown as JsonValue);
}
export function getStoredSidebarActiveViewId(fallback: string): string {
return getLocalStorage(SIDEBAR_ACTIVE_VIEW_KEY, fallback);
}
export function setStoredSidebarActiveViewId(id: string): void {
setLocalStorage(SIDEBAR_ACTIVE_VIEW_KEY, id);
}
export function getStoredSidebarDraft<T>(fallback: T): T {
return getLocalStorage(SIDEBAR_DRAFT_KEY, fallback as unknown as JsonValue) as unknown as T;
}
export function setStoredSidebarDraft<T>(draft: T): void {
setLocalStorage(SIDEBAR_DRAFT_KEY, draft as unknown as JsonValue);
}
export function removeStoredSidebarDraft(): void {
removeLocalStorage(SIDEBAR_DRAFT_KEY);
}
// --- Task creation draft persistence (sessionStorage, per workspace) ---
const TASK_CREATE_DRAFT_KEY = "kandev.taskCreateDraft";
/**
* Draft data for task creation dialog.
* Only persists user-entered content fields (title, description).
* Other fields (repo, branch, agent) are handled by "last used" localStorage.
*/
export type TaskCreateDraft = {
title: string;
description: string;
};
/**
* Get the saved task creation draft for a workspace.
* @param workspaceId - The workspace ID
* @returns The draft data, or null if no draft exists
*/
export function getTaskCreateDraft(workspaceId: string): TaskCreateDraft | null {
if (!workspaceId) return null;
return getSessionStorage<TaskCreateDraft | null>(`${TASK_CREATE_DRAFT_KEY}.${workspaceId}`, null);
}
/**
* Save a task creation draft for a workspace.
* @param workspaceId - The workspace ID
* @param draft - The draft data to save
*/
export function setTaskCreateDraft(workspaceId: string, draft: TaskCreateDraft): void {
if (!workspaceId) return;
// Only save if there's actual content
if (!draft.title.trim() && !draft.description.trim()) {
removeTaskCreateDraft(workspaceId);
return;
}
setSessionStorage(`${TASK_CREATE_DRAFT_KEY}.${workspaceId}`, draft);
}
/**
* Remove the task creation draft for a workspace.
* @param workspaceId - The workspace ID
*/
export function removeTaskCreateDraft(workspaceId: string): void {
if (!workspaceId) return;
removeSessionStorage(`${TASK_CREATE_DRAFT_KEY}.${workspaceId}`);
}