Files
kandev/apps/web/lib/recent-tasks.ts
T
José Almeida 1394b7103f feat: add recent task switcher (#779)
* feat: add recent task switcher

* fix: address task switcher review feedback

* test: cover recent task cleanup

* perf: reduce recent task switcher storage reads
2026-05-02 22:25:40 +01:00

101 lines
3.3 KiB
TypeScript

import type { TaskSessionState, TaskState } from "@/lib/types/http";
export const RECENT_TASKS_STORAGE_KEY = "kandev.recentTasks.v1";
export const RECENT_TASKS_CHANGED_EVENT = "kandev:recent-tasks-changed";
export const MAX_RECENT_TASKS = 12;
export type RecentTaskEntry = {
taskId: string;
title: string;
visitedAt: string;
taskState?: TaskState | null;
sessionState?: TaskSessionState | null;
repositoryPath?: string | null;
workflowId?: string | null;
workflowName?: string | null;
workflowStepTitle?: string | null;
workspaceId?: string | null;
};
type RecentTasksChangedDetail = {
entries: RecentTaskEntry[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function optionalString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function normalizeEntry(value: unknown): RecentTaskEntry | null {
if (!isRecord(value)) return null;
const taskId = optionalString(value.taskId);
if (!taskId) return null;
const title = optionalString(value.title) ?? "Untitled task";
const visitedAt = optionalString(value.visitedAt) ?? new Date(0).toISOString();
return {
taskId,
title,
visitedAt,
taskState: optionalString(value.taskState) as TaskState | null,
sessionState: optionalString(value.sessionState) as TaskSessionState | null,
repositoryPath: optionalString(value.repositoryPath),
workflowId: optionalString(value.workflowId),
workflowName: optionalString(value.workflowName),
workflowStepTitle: optionalString(value.workflowStepTitle),
workspaceId: optionalString(value.workspaceId),
};
}
function normalizeEntries(value: unknown): RecentTaskEntry[] {
if (!Array.isArray(value)) return [];
return value
.map(normalizeEntry)
.filter((entry): entry is RecentTaskEntry => entry !== null)
.slice(0, MAX_RECENT_TASKS);
}
function emitRecentTasksChanged(entries: RecentTaskEntry[]): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<RecentTasksChangedDetail>(RECENT_TASKS_CHANGED_EVENT, {
detail: { entries },
}),
);
}
export function getRecentTasks(): RecentTaskEntry[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(RECENT_TASKS_STORAGE_KEY);
if (!raw) return [];
return normalizeEntries(JSON.parse(raw));
} catch {
return [];
}
}
export function setRecentTasks(entries: RecentTaskEntry[]): RecentTaskEntry[] {
const normalized = normalizeEntries(entries);
if (typeof window === "undefined") return normalized;
try {
window.localStorage.setItem(RECENT_TASKS_STORAGE_KEY, JSON.stringify(normalized));
emitRecentTasksChanged(normalized);
} catch {
// Ignore write failures: private browsing, blocked storage, or quota issues.
}
return normalized;
}
export function upsertRecentTask(entry: RecentTaskEntry): RecentTaskEntry[] {
const current = getRecentTasks().filter((item) => item.taskId !== entry.taskId);
return setRecentTasks([entry, ...current].slice(0, MAX_RECENT_TASKS));
}
export function removeRecentTask(taskId: string): RecentTaskEntry[] {
return setRecentTasks(getRecentTasks().filter((entry) => entry.taskId !== taskId));
}