Files
kandev/apps/web/lib/local-storage.ts
T
João Salavisa 35242b2982 feat(web): drag-reorder subtasks within a parent task (#962)
* feat(web): drag-reorder subtasks within a parent task

Adds per-parent subtask ordering in the sidebar. Drag a subtask under its
parent to reorder; the chosen order is independent of the global sort and
persists per browser via localStorage.

- New `sidebarTaskPrefs.subtaskOrderByParentId` slice field and matching
  `setSubtaskOrder(parentId, ids)` action, persisted to localStorage under
  `kandev.sidebar.subtaskOrderByParentId`.
- `applyView` applies the per-parent override after `separateSubtasks`, so
  it never touches root order or the active sort spec.
- TaskSwitcher exposes `onReorderSubtasks(parentId, ids)`; each parent's
  subtask list lives in its own nested DndContext / SortableContext using
  the existing dnd-kit setup.
- Task-deletion cleanup strips the deleted id as both parent key and
  subtask entry from `subtaskOrderByParentId` in store + localStorage.

* refactor(web): address PR review feedback on subtask reordering

- Extract `pruneSubtaskOrder(map, taskId)` helper in `local-storage.ts`;
  use it in both `cleanupTaskStorage` and `removeTaskFromSidebarPrefs`
  to keep the two cleanup paths in lockstep.
- Move `SortableSubtaskRow`, `SortableSubtaskList`, and `useSubtaskDnd`
  into a new sibling file `task-switcher-subtask-dnd.tsx` so the
  switcher stays under the 600-line soft limit.
- `SortableSubtaskList` now short-circuits to a plain row map when
  `onReorderSubtasks` is undefined — read-only callers no longer get a
  misleading grab cursor or unused DnD scaffolding.

* fix(web): add stable keys to non-DnD subtask render path

When `onReorderSubtasks` is undefined, the early-return branch in
`SortableSubtaskList` mapped subtasks without keys, falling back to
React's index-based reconciliation. Wrap each child in a keyed
`Fragment` so reorders or size changes reconcile correctly.

* refactor(web): single-source DRAG_ACTIVATION_DISTANCE

Export the constant from `task-switcher-subtask-dnd.tsx` and import it
in `task-switcher.tsx` instead of declaring the same value twice.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 01:31:42 +01:00

815 lines
26 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 = {
EXPANDED: "kandev.filesPanel.expanded",
SCROLL: "kandev.filesPanel.scroll",
} as const;
/**
* 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) ---
// Dockview layout is keyed by `taskEnvironmentId` so sessions sharing a task
// env reuse one layout (the env owns the workspace, terminals, files, etc.).
const DOCKVIEW_ENV_LAYOUT_PREFIX = "kandev.dockview.env-layout.";
/**
* Get the saved dockview layout for a task environment.
* Returns null if not found.
*/
export function getEnvLayout(envId: string): object | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(`${DOCKVIEW_ENV_LAYOUT_PREFIX}${envId}`);
if (!raw) return null;
return JSON.parse(raw) as object;
} catch {
return null;
}
}
/** Save the dockview layout for a task environment. */
export function setEnvLayout(envId: string, layout: object): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(`${DOCKVIEW_ENV_LAYOUT_PREFIX}${envId}`, JSON.stringify(layout));
} catch {
// Ignore write failures (storage full, blocked, etc.)
}
}
// --- Dockview per-env maximize state (sessionStorage) ---
const DOCKVIEW_ENV_MAXIMIZE_PREFIX = "kandev.dockview.env-maximize.";
export type EnvMaximizeState = {
/** The pre-maximize (normal) layout to restore on exit-maximize. */
preMaximizeLayout: object;
/** Native dockview JSON (api.toJSON()) for the maximized layout. */
maximizedDockviewJson: object;
};
function isEnvMaximizeState(value: unknown): value is EnvMaximizeState {
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 getEnvMaximizeState(envId: string): EnvMaximizeState | null {
if (typeof window === "undefined") return null;
try {
const raw = window.sessionStorage.getItem(`${DOCKVIEW_ENV_MAXIMIZE_PREFIX}${envId}`);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return isEnvMaximizeState(parsed) ? parsed : null;
} catch {
return null;
}
}
export function setEnvMaximizeState(envId: string, state: EnvMaximizeState): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.setItem(`${DOCKVIEW_ENV_MAXIMIZE_PREFIX}${envId}`, JSON.stringify(state));
} catch {
// Ignore write failures
}
}
export function removeEnvMaximizeState(envId: string): void {
if (typeof window === "undefined") return;
try {
window.sessionStorage.removeItem(`${DOCKVIEW_ENV_MAXIMIZE_PREFIX}${envId}`);
} 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/env-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[],
envIds: string[] = [],
): void {
// Plan notification (localStorage, keyed per task inside a Record)
setPlanLastSeen(taskId, null);
// Sidebar collapsed-subtask set (sessionStorage, array keyed by parent taskId)
const collapsed = getStoredCollapsedSubtaskParents();
if (collapsed.includes(taskId)) {
setStoredCollapsedSubtaskParents(collapsed.filter((id) => id !== taskId));
}
// Sidebar pin + manual-order arrays. Strip the deleted task so the lists
// don't grow unboundedly across reloads.
const pinned = getStoredPinnedTaskIds();
if (pinned.includes(taskId)) {
setStoredPinnedTaskIds(pinned.filter((id) => id !== taskId));
}
const ordered = getStoredOrderedTaskIds();
if (ordered.includes(taskId)) {
setStoredOrderedTaskIds(ordered.filter((id) => id !== taskId));
}
// Per-parent subtask order: drop the deleted task as a parent key, and strip
// it from any other parent's subtask-order list (in case it was a subtask).
const subOrder = getStoredSubtaskOrderByParentId();
if (pruneSubtaskOrder(subOrder, taskId)) setStoredSubtaskOrderByParentId(subOrder);
// Env-keyed storage — dockview layout + maximize live under task envs.
for (const envId of envIds) {
removeEnvMaximizeState(envId);
removeSessionStorage(`${DOCKVIEW_ENV_LAYOUT_PREFIX}${envId}`);
}
// Session-keyed storage — drafts, files panel state, scroll, etc.
for (const sessionId of sessionIds) {
removeStoredQuickChatName(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.EXPANDED}.${sessionId}`);
removeSessionStorage(`${FILES_PANEL_KEYS.SCROLL}.${sessionId}`);
removeSessionStorage(`${OPEN_FILES_KEY}.${sessionId}`);
removeSessionStorage(`${ACTIVE_TAB_KEY}.${sessionId}`);
removeSessionStorage(`kandev.contextFiles.${sessionId}`);
removeSessionStorage(`kandev.comments.${sessionId}`);
}
}
// --- Quick chat custom names (localStorage, global) ---
//
// Local-only: not persisted to the backend. Keyed by sessionId so the rename
// follows the underlying chat across reloads but vanishes if the user clears
// browser storage. The backend task title remains the auto-generated name.
const QUICK_CHAT_NAMES_KEY = "kandev.quickChat.names";
export function getStoredQuickChatNames(): Record<string, string> {
const raw = getLocalStorage<Record<string, string>>(QUICK_CHAT_NAMES_KEY, {});
if (!raw || typeof raw !== "object") return {};
return raw;
}
export function setStoredQuickChatName(sessionId: string, name: string): void {
if (!sessionId) return;
const all = getStoredQuickChatNames();
if (name) all[sessionId] = name;
else delete all[sessionId];
setLocalStorage(QUICK_CHAT_NAMES_KEY, all);
}
export function removeStoredQuickChatName(sessionId: string): void {
if (!sessionId) return;
const all = getStoredQuickChatNames();
if (!(sessionId in all)) return;
delete all[sessionId];
setLocalStorage(QUICK_CHAT_NAMES_KEY, all);
}
// --- 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);
}
// --- Sidebar task prefs: pin + manual order (localStorage, global) ---
const SIDEBAR_PINNED_TASKS_KEY = "kandev.sidebar.pinnedTaskIds";
const SIDEBAR_TASK_ORDER_KEY = "kandev.sidebar.orderedTaskIds";
function readStringArray(key: string): string[] {
const raw = getLocalStorage<string[]>(key, []) as unknown;
if (!Array.isArray(raw)) return [];
return raw.filter((id): id is string => typeof id === "string");
}
export function getStoredPinnedTaskIds(): string[] {
return readStringArray(SIDEBAR_PINNED_TASKS_KEY);
}
export function setStoredPinnedTaskIds(ids: string[]): void {
setLocalStorage(SIDEBAR_PINNED_TASKS_KEY, ids);
}
export function getStoredOrderedTaskIds(): string[] {
return readStringArray(SIDEBAR_TASK_ORDER_KEY);
}
export function setStoredOrderedTaskIds(ids: string[]): void {
setLocalStorage(SIDEBAR_TASK_ORDER_KEY, ids);
}
const SIDEBAR_SUBTASK_ORDER_KEY = "kandev.sidebar.subtaskOrderByParentId";
export function getStoredSubtaskOrderByParentId(): Record<string, string[]> {
const raw = getLocalStorage<Record<string, string[]>>(SIDEBAR_SUBTASK_ORDER_KEY, {}) as unknown;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: Record<string, string[]> = {};
for (const [parentId, ids] of Object.entries(raw as Record<string, unknown>)) {
if (typeof parentId !== "string" || !Array.isArray(ids)) continue;
const filtered = ids.filter((id): id is string => typeof id === "string");
if (filtered.length > 0) out[parentId] = filtered;
}
return out;
}
export function setStoredSubtaskOrderByParentId(map: Record<string, string[]>): void {
setLocalStorage(SIDEBAR_SUBTASK_ORDER_KEY, map);
}
/**
* Strip a task id from a subtask-order map: drop it as a parent key, and
* remove it from every other parent's subtask list (cleaning up the parent
* entry if its list becomes empty). Mutates `map` in place and returns
* `true` if anything changed. Used by both `cleanupTaskStorage` (plain
* object) and `removeTaskFromSidebarPrefs` (Immer draft) to keep the two
* cleanup paths in lockstep.
*/
export function pruneSubtaskOrder(map: Record<string, string[]>, taskId: string): boolean {
let changed = false;
if (taskId in map) {
delete map[taskId];
changed = true;
}
for (const [parentId, ids] of Object.entries(map)) {
if (!ids.includes(taskId)) continue;
const next = ids.filter((id) => id !== taskId);
if (next.length === 0) delete map[parentId];
else map[parentId] = next;
changed = true;
}
return changed;
}
// --- Sidebar collapsed subtask parents (sessionStorage, tab-scoped) ---
const COLLAPSED_SUBTASKS_KEY = "kandev.sidebar.collapsedSubtasks";
/**
* Get the list of parent task IDs whose subtasks are collapsed in the sidebar.
* Tab-scoped (sessionStorage) so it survives reload/task switches but not tab close.
*/
export function getStoredCollapsedSubtaskParents(): string[] {
const raw = getSessionStorage<string[]>(COLLAPSED_SUBTASKS_KEY, []) as unknown;
if (!Array.isArray(raw)) return [];
return raw.filter((id): id is string => typeof id === "string");
}
/**
* Save the list of parent task IDs whose subtasks are collapsed in the sidebar.
*/
export function setStoredCollapsedSubtaskParents(ids: string[]): void {
setSessionStorage(COLLAPSED_SUBTASKS_KEY, ids);
}
// --- 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}`);
}