diff --git a/packages/core/src/canvas/dashboardSchemas.ts b/packages/core/src/canvas/dashboardSchemas.ts index 80c003366a..0020ea9797 100644 --- a/packages/core/src/canvas/dashboardSchemas.ts +++ b/packages/core/src/canvas/dashboardSchemas.ts @@ -71,6 +71,10 @@ export const dashboardSummarySchema = z.object({ // The React source, included so the grid can render a live preview without an // N+1 of get()s (it rides in the FS row's meta, already loaded when listing). code: z.string().optional(), + // Id of the task currently generating this canvas (see dashboardRecordSchema). + // Surfaced on the summary so the sidebar can show the run nested under the + // canvas without a per-canvas get(). + generationTaskId: z.string().nullish(), }); export type DashboardSummary = z.infer; diff --git a/packages/core/src/canvas/dashboardsService.ts b/packages/core/src/canvas/dashboardsService.ts index 627db8fd58..59df11649f 100644 --- a/packages/core/src/canvas/dashboardsService.ts +++ b/packages/core/src/canvas/dashboardsService.ts @@ -90,6 +90,7 @@ export class DashboardsService { createdBy, updatedAt, code, + generationTaskId, }) => ({ id, channelId: cid, @@ -98,6 +99,7 @@ export class DashboardsService { createdBy, updatedAt, code, + generationTaskId, }), ); } diff --git a/packages/core/src/sidebar/buildSidebarData.ts b/packages/core/src/sidebar/buildSidebarData.ts index 7c61c08a1e..5b41353260 100644 --- a/packages/core/src/sidebar/buildSidebarData.ts +++ b/packages/core/src/sidebar/buildSidebarData.ts @@ -1,4 +1,4 @@ -import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; import { getRepositoryInfo } from "./groupTasks"; import type { TaskData } from "./sidebarData.types"; @@ -35,7 +35,10 @@ export interface SidebarTask { } | null; } -export function narrowFullTask(task: FullTask): SidebarTask { +// Accepts both the local `FullTask` shape and the canonical `Task` from +// `@posthog/shared` so callers holding a real `Task` can narrow it directly, +// without an `as unknown as FullTask` escape hatch. +export function narrowFullTask(task: FullTask | Task): SidebarTask { const slackThreadUrl = task.latest_run?.state?.slack_thread_url; return { id: task.id, diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 75e85211c3..31a71bfc7d 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -1,5 +1,6 @@ import { ArchiveIcon, + ArrowElbowDownRightIcon, CaretDownIcon, ChartBarIcon, CodeIcon, @@ -14,6 +15,7 @@ import { XIcon, } from "@phosphor-icons/react"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; +import type { TaskData } from "@posthog/core/sidebar/sidebarData.types"; import { AlertDialogClose, AlertDialogContent, @@ -49,7 +51,7 @@ import { TooltipProvider, TooltipTrigger, } from "@posthog/quill"; - +import type { WorkspaceMode } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; @@ -84,8 +86,13 @@ import { useOpenHomeCanvas, usePrefetchDashboards, } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useNestedGenerationTaskIds } from "@posthog/ui/features/canvas/hooks/useNestedGenerationTaskIds"; +import { useSessionForTask } from "@posthog/ui/features/sessions/useSession"; import { TaskIcon } from "@posthog/ui/features/sidebar/components/items/TaskIcon"; -import { useTaskPrStatus } from "@posthog/ui/features/sidebar/useTaskPrStatus"; +import { + type SidebarPrState, + useTaskPrStatus, +} from "@posthog/ui/features/sidebar/useTaskPrStatus"; import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useWorkspace } from "@posthog/ui/features/workspace/useWorkspace"; @@ -382,16 +389,304 @@ function ChildRow({ ); } +// Shared right-click menu + hover tooltip for a task row inside a channel: +// File to… / Archive / Remove from channel. Used by both the regular filed +// TaskRow and the generation task nested under a canvas so they offer the same +// actions. "Remove from channel" only appears when the task is actually filed +// (has a channel task row) — `channelTaskId` is what `unfileTask` removes. +function TaskRowContextMenu({ + channelId, + taskId, + channelTaskId, + title, + channels, + children, +}: { + channelId: string; + taskId: string; + channelTaskId?: string; + title: string; + channels: Channel[]; + children: ReactNode; +}) { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const { fileTask, unfileTask } = useChannelTaskMutations(); + // Archiving from the bluebird/channels nav should return to the website + // new-task screen, not the Code one. + const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); + + const onFileTo = async (targetChannelId: string) => { + try { + await fileTask(targetChannelId, taskId, title); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "file_task", + surface: "sidebar", + channel_id: channelId, + target_channel_id: targetChannelId, + task_id: taskId, + success: true, + }); + } catch (error) { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "file_task", + surface: "sidebar", + channel_id: channelId, + target_channel_id: targetChannelId, + task_id: taskId, + success: false, + }); + toast.error("Couldn't file task", { + description: error instanceof Error ? error.message : String(error), + }); + } + }; + + const onArchive = async () => { + try { + await archiveTask({ taskId }); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "archive_task", + surface: "sidebar", + channel_id: channelId, + task_id: taskId, + success: true, + }); + } catch (error) { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "archive_task", + surface: "sidebar", + channel_id: channelId, + task_id: taskId, + success: false, + }); + toast.error("Couldn't archive task", { + description: error instanceof Error ? error.message : String(error), + }); + } + }; + + const onRemove = async () => { + if (!channelTaskId) return; + try { + await unfileTask(channelTaskId); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "unfile_task", + surface: "sidebar", + channel_id: channelId, + task_id: taskId, + success: true, + }); + if (pathname === `/website/${channelId}/tasks/${taskId}`) { + void navigate({ + to: "/website/$channelId", + params: { channelId }, + }); + } + } catch (error) { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "unfile_task", + surface: "sidebar", + channel_id: channelId, + task_id: taskId, + success: false, + }); + toast.error("Couldn't remove task from channel", { + description: error instanceof Error ? error.message : String(error), + }); + } + }; + + return ( + + + {children}} + /> + {title} + + + + + + File to… + + + {channels.filter((c) => c.id !== channelId).length === 0 ? ( + No other channels + ) : ( + channels + .filter((c) => c.id !== channelId) + .map((c) => ( + void onFileTo(c.id)} + > + {c.name} + + )) + )} + + + + void onArchive()}> + + Archive + + {channelTaskId ? ( + void onRemove()} + > + + Remove from channel + + ) : null} + + + ); +} + +// The status icon shared by both channel task rows. Maps a row's derived +// `TaskData` onto the sidebar `` (cloud run status, PR state, +// generating / unread / pinned, etc.), falling back to a neutral code icon +// until the data loads. Defined once so `TaskRow` and `CanvasGenerationTaskRow` +// can't drift apart on icon fidelity. +function TaskStatusIcon({ + taskData, + prState, + hasDiff, + workspaceMode, + size, +}: { + taskData: TaskData | undefined; + prState: SidebarPrState; + hasDiff: boolean; + workspaceMode: WorkspaceMode | undefined; + size: number; +}) { + if (!taskData) { + return ; + } + return ( + + ); +} + +// The generation task tied to a canvas, shown nested beneath the canvas name +// while it's generating and afterwards until the user has seen the result (see +// useNestedGenerationTaskIds — the parent only renders this row when it should +// nest). Unlike a filed TaskRow this is a compact, single-line row — just the +// task icon and title (no status subtitle) — with a down-then-right elbow +// marking it as belonging to the canvas above it. Clicking opens the task; +// right-click offers the same actions as a regular task row. +function CanvasGenerationTaskRow({ + channelId, + taskId, + task, + channelTaskId, + channels, +}: { + channelId: string; + taskId: string; + task: Task | undefined; + channelTaskId?: string; + channels: Channel[]; +}) { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const taskData = useChannelTaskData(task); + const workspace = useWorkspace(taskId); + const workspaceMode = + workspace?.mode ?? + (taskData?.taskRunEnvironment === "cloud" ? "cloud" : undefined); + const { prState, hasDiff } = useTaskPrStatus({ + id: taskId, + cloudPrUrl: taskData?.cloudPrUrl ?? null, + taskRunEnvironment: taskData?.taskRunEnvironment ?? null, + }); + + // Tasks are private to their creator; if the generation task isn't in this + // user's list there's nothing to link to, so render nothing. + if (!task) return null; + + const title = task.title || "Untitled task"; + const active = pathname === `/website/${channelId}/tasks/${taskId}`; + const icon = ( + + ); + + return ( + + + + ); +} + // A single saved canvas under a channel — navigates to its detail view, with a // right-click menu to rename (inline) or delete it. function DashboardRow({ channelId, dashboard, active, + generationTask, + generationChannelTaskId, + channels, }: { channelId: string; dashboard: DashboardSummary; active: boolean; + // The canvas's generation task, when it should be shown nested below the + // canvas name (decided by the channel via useNestedGenerationTaskIds). + // Undefined when there's nothing to nest. + generationTask?: Task; + generationChannelTaskId?: string; + channels: Channel[]; }) { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); @@ -539,6 +834,16 @@ function DashboardRow({ + {generationTask ? ( + + ) : null} + @@ -566,8 +871,8 @@ function DashboardRow({ ); } -// Right-click "File to..." submenu on a task row. Files the task to another -// channel by creating an extra `task` FS row under that folder. +// A filed task under a channel: the live status icon + title, with the shared +// right-click menu (File to… / Archive / Remove from channel). function TaskRow({ channelTaskId, channelId, @@ -587,13 +892,8 @@ function TaskRow({ onClick: () => void; channels: Channel[]; }) { - const navigate = useNavigate(); - const pathname = useRouterState({ select: (s) => s.location.pathname }); - const { fileTask, unfileTask } = useChannelTaskMutations(); - // Archiving from the bluebird/channels nav should return to the website - // new-task screen, not the Code one. - const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); const taskData = useChannelTaskData(task); + const session = useSessionForTask(taskId); const workspace = useWorkspace(taskId); const workspaceMode = workspace?.mode ?? @@ -603,164 +903,51 @@ function TaskRow({ cloudPrUrl: taskData?.cloudPrUrl ?? null, taskRunEnvironment: taskData?.taskRunEnvironment ?? null, }); - const icon = taskData ? ( - - ) : ( - ); // A short status word under the title (running / merged / …), mirroring the - // task's live state. Falls back to the run status when there's no PR yet. + // task's live state. Repo-less local tasks (e.g. canvas generation) have no + // backend run record, so `taskRunStatus` is undefined once the turn ends — + // fall back to the live session so the row still shows a status line. A + // session still mid-handshake ("connecting") is on its way to generating, so + // treat it as running rather than letting it flash "completed". const status = taskData?.isGenerating === true ? "running" - : (prState ?? taskData?.taskRunStatus ?? undefined); - - const onFileTo = async (targetChannelId: string) => { - try { - await fileTask(targetChannelId, taskId, title); - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "file_task", - surface: "sidebar", - channel_id: channelId, - target_channel_id: targetChannelId, - task_id: taskId, - success: true, - }); - } catch (error) { - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "file_task", - surface: "sidebar", - channel_id: channelId, - target_channel_id: targetChannelId, - task_id: taskId, - success: false, - }); - toast.error("Couldn't file task", { - description: error instanceof Error ? error.message : String(error), - }); - } - }; - - const onArchive = async () => { - try { - await archiveTask({ taskId }); - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "archive_task", - surface: "sidebar", - channel_id: channelId, - task_id: taskId, - success: true, - }); - } catch (error) { - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "archive_task", - surface: "sidebar", - channel_id: channelId, - task_id: taskId, - success: false, - }); - toast.error("Couldn't archive task", { - description: error instanceof Error ? error.message : String(error), - }); - } - }; - - const onRemove = async () => { - try { - await unfileTask(channelTaskId); - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "unfile_task", - surface: "sidebar", - channel_id: channelId, - task_id: taskId, - success: true, - }); - if (pathname === `/website/${channelId}/tasks/${taskId}`) { - void navigate({ - to: "/website/$channelId", - params: { channelId }, - }); - } - } catch (error) { - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "unfile_task", - surface: "sidebar", - channel_id: channelId, - task_id: taskId, - success: false, - }); - toast.error("Couldn't remove task from channel", { - description: error instanceof Error ? error.message : String(error), - }); - } - }; + : (prState ?? + taskData?.taskRunStatus ?? + (session + ? session.status === "error" + ? "failed" + : session.status === "connecting" + ? "running" + : "completed" + : undefined)); return ( - - - - - - } - /> - {title} - - - - - - File to… - - - {channels.filter((c) => c.id !== channelId).length === 0 ? ( - No other channels - ) : ( - channels - .filter((c) => c.id !== channelId) - .map((c) => ( - void onFileTo(c.id)} - > - {c.name} - - )) - )} - - - - void onArchive()}> - - Archive - - void onRemove()}> - - Remove from channel - - - + + + ); } @@ -846,10 +1033,26 @@ function ChannelSection({ const taskUpdatedAtMs = new Map( tasks?.map((t) => [t.id, Date.parse(t.updated_at) || 0]) ?? [], ); + // A canvas's generation task is shown nested under the canvas while it's + // generating (and until the user has seen the result); don't also list it + // flat below. Once it drops out of this set it reappears in the regular list + // (if filed there). The currently-open task stays nested so it doesn't jump + // out from under the canvas while still being viewed. + const openTaskPrefix = `${base}/tasks/`; + const openTaskId = pathname.startsWith(openTaskPrefix) + ? pathname.slice(openTaskPrefix.length).split("/")[0] + : undefined; + const nestedGenerationTaskIds = useNestedGenerationTaskIds( + dashboards, + tasks, + openTaskId, + ); const visibleFiledTasks = filedTasks .filter( ({ taskId }) => - !archivedTaskIds.has(taskId) && taskUpdatedAtMs.has(taskId), + !archivedTaskIds.has(taskId) && + taskUpdatedAtMs.has(taskId) && + !nestedGenerationTaskIds.has(taskId), ) .sort( (a, b) => @@ -1021,14 +1224,30 @@ function ChannelSection({ gap="px" className="mt-px ml-[11px] border-gray-6 border-l pl-2 empty:hidden" > - {dashboards.map((d) => ( - - ))} + {dashboards.map((d) => { + const genTaskId = d.generationTaskId; + const showGen = + !!genTaskId && nestedGenerationTaskIds.has(genTaskId); + return ( + t.id === genTaskId) + : undefined + } + generationChannelTaskId={ + showGen + ? filedTasks.find((f) => f.taskId === genTaskId)?.id + : undefined + } + /> + ); + })} {displayedFiledTasks.map(({ id: channelTaskId, taskId }) => { const task = tasks?.find((t) => t.id === taskId); const title = task?.title || "Untitled task"; diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx index 5002205018..1259889e7c 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx @@ -17,6 +17,7 @@ import { EmptyMedia, EmptyTitle, } from "@posthog/quill"; +import { isTerminalStatus } from "@posthog/shared/domain-types"; import { isCanvasGenerationRunning } from "@posthog/ui/features/canvas/freeform/canvasGenerationStatus"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { @@ -100,21 +101,45 @@ export function FreeformCanvasView({ refetchInterval: genTaskId ? 5000 : false, }); const genSession = useSessionForTask(genTaskId ?? undefined); - const running = isCanvasGenerationRunning({ + // Whether the run's session is still alive. Drives record polling so a freshly + // published canvas gets picked up. A local ACP session stays "connected" after + // its generation prompt finishes, so this keeps syncing until it disconnects. + // Uses the shared, tested helper, which also stops once the run record is + // terminal so a stale/stuck session can't keep us polling forever. + const isSyncing = isCanvasGenerationRunning({ genTaskId, genTaskLoading, latestRun: genTask?.latest_run, session: genSession, }); - const isGenerating = !!genTaskId && running; + // Whether the agent is actively producing the canvas right now. Drives the + // "Generating…" UI (notice, composer, undo/redo). A local session stays + // "connected" after its single generation prompt completes, so key off the + // pending prompt, not the connection — otherwise the notice never clears. A + // terminal run record always wins so a stuck session can't strand the notice. + const isGenerating = (() => { + if (!genTaskId) return false; + if (genTaskLoading) return true; + if (genTask?.latest_run?.environment === "cloud") { + const cloudStatus = + genSession?.cloudStatus ?? genTask?.latest_run?.status ?? null; + return !isTerminalStatus(cloudStatus); + } + if (isTerminalStatus(genTask?.latest_run?.status)) return false; + return ( + genSession?.status === "connecting" || + genSession?.isPromptPending === true + ); + })(); - // Poll the record while generating so a just-published canvas appears. + // Poll the record while the session is alive so a just-published canvas + // appears (the publish lands while the prompt is still pending). useQuery( trpc.dashboards.get.queryOptions( { id: dashboardId }, { - enabled: !!dashboardId && isGenerating, - refetchInterval: isGenerating ? 4000 : false, + enabled: !!dashboardId && isSyncing, + refetchInterval: isSyncing ? 4000 : false, }, ), ); diff --git a/packages/ui/src/features/canvas/hooks/useChannelTaskData.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskData.ts index c4f68ba1f8..e1e685f534 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelTaskData.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskData.ts @@ -1,6 +1,5 @@ import { deriveTaskData, - type FullTask, narrowFullTask, type TaskSession, } from "@posthog/core/sidebar/buildSidebarData"; @@ -30,7 +29,7 @@ export function useChannelTaskData( return useMemo(() => { if (!task) return undefined; - const sidebarTask = narrowFullTask(task as unknown as FullTask); + const sidebarTask = narrowFullTask(task); return deriveTaskData(sidebarTask, { session: session as TaskSession | undefined, workspace: workspace ?? undefined, diff --git a/packages/ui/src/features/canvas/hooks/useNestedGenerationTaskIds.ts b/packages/ui/src/features/canvas/hooks/useNestedGenerationTaskIds.ts new file mode 100644 index 0000000000..031ed34423 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useNestedGenerationTaskIds.ts @@ -0,0 +1,74 @@ +import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; +import { + deriveTaskData, + narrowFullTask, + type TaskSession, +} from "@posthog/core/sidebar/buildSidebarData"; +import type { Task } from "@posthog/shared/domain-types"; +import { useSessions } from "@posthog/ui/features/sessions/useSession"; +import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; +import { useMemo } from "react"; + +const EMPTY_SET: ReadonlySet = new Set(); +const EMPTY_STRING_MAP: ReadonlyMap = new Map(); + +// Which canvas generation tasks should be shown nested under their canvas in +// the channel tree. A generation task nests while it's actively generating, and +// stays nested afterwards until the user has actually looked at the task — i.e. +// there's activity they haven't seen. (A never-viewed task counts as unseen, so +// it stays put rather than vanishing the instant it finishes.) The task being +// viewed right now stays nested too, so it doesn't jump out from under the +// canvas while the user is still on its task view (opening it marks it viewed); +// it drops into the channel's regular list once they navigate away. Sending a +// follow-up starts it generating again and re-nests it. +// +// Derived in bulk from one sessions + timestamps read (rather than per row) so +// the channel can both render the nested rows and dedupe them out of the flat +// task list from a single source of truth, with no chance of the two diverging. +export function useNestedGenerationTaskIds( + dashboards: DashboardSummary[], + tasks: Task[] | undefined, + openTaskId: string | undefined, +): ReadonlySet { + const sessions = useSessions(); + const { timestamps } = useTaskViewed(); + + return useMemo(() => { + const generationTaskIds = dashboards + .map((d) => d.generationTaskId) + .filter((id): id is string => !!id); + if (generationTaskIds.length === 0) return EMPTY_SET; + + const sessionByTaskId = new Map(); + for (const session of Object.values(sessions)) { + if (session.taskId) sessionByTaskId.set(session.taskId, session); + } + const taskById = new Map(tasks?.map((t) => [t.id, t]) ?? []); + + const nested = new Set(); + for (const taskId of generationTaskIds) { + const task = taskById.get(taskId); + // Tasks are private to their creator; one that isn't in our list can't be + // shown (or deduped) — leave it out. + if (!task) continue; + const data = deriveTaskData(narrowFullTask(task), { + session: sessionByTaskId.get(taskId) as TaskSession | undefined, + workspace: undefined, + timestamp: timestamps[taskId], + pinnedIds: EMPTY_SET, + suspendedIds: EMPTY_SET, + slackTaskIds: EMPTY_SET, + slackThreadUrlByTaskId: EMPTY_STRING_MAP, + }); + // `isUnread` requires a prior view (lastViewedAt set); a never-viewed + // task isn't "unread" but is still unseen, so check activity-vs-view + // directly to keep a just-finished, never-opened task nested. + const lastViewedAt = timestamps[taskId]?.lastViewedAt; + const hasUnseenActivity = + lastViewedAt == null || data.lastActivityAt > lastViewedAt; + if (data.isGenerating || hasUnseenActivity || taskId === openTaskId) + nested.add(taskId); + } + return nested; + }, [dashboards, tasks, sessions, timestamps, openTaskId]); +} diff --git a/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx b/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx index 6d9ba6ab26..a5f0349e21 100644 --- a/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx +++ b/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx @@ -1,5 +1,6 @@ import type { Task } from "@posthog/shared/domain-types"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; import { TaskDetail } from "@posthog/ui/features/task-detail/components/TaskDetail"; import { getCachedTask, @@ -10,6 +11,7 @@ import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { RoutePending } from "@posthog/ui/router/RoutePending"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; +import { useEffect } from "react"; export const Route = createFileRoute("/website/$channelId/tasks/$taskId")({ component: ChannelTaskDetailRoute, @@ -27,6 +29,15 @@ function ChannelTaskDetailRoute() { const { channels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name; + // The channels space doesn't mount SidebarMenu (which marks code-space tasks + // viewed on open), so mark it viewed here. Clears the task's unread state and + // lets a canvas's generation task drop out of the nested sidebar row once the + // user has actually looked at it. + const { markAsViewed } = useTaskViewed(); + useEffect(() => { + markAsViewed(taskId); + }, [taskId, markAsViewed]); + const { data: fetched } = useQuery({ ...taskDetailQuery(taskId), enabled: !fromList && !loaderTask, diff --git a/packages/workspace-server/src/db/identifiers.ts b/packages/workspace-server/src/db/identifiers.ts index d683146cb1..1125aea557 100644 --- a/packages/workspace-server/src/db/identifiers.ts +++ b/packages/workspace-server/src/db/identifiers.ts @@ -24,3 +24,6 @@ export const AUTH_PREFERENCE_REPOSITORY = Symbol.for( export const DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY = Symbol.for( "posthog.workspace.defaultAdditionalDirectoryRepository", ); +export const TASK_METADATA_REPOSITORY = Symbol.for( + "posthog.workspace.taskMetadataRepository", +); diff --git a/packages/workspace-server/src/db/migrations/0010_wet_norrin_radd.sql b/packages/workspace-server/src/db/migrations/0010_wet_norrin_radd.sql new file mode 100644 index 0000000000..1584eccf53 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0010_wet_norrin_radd.sql @@ -0,0 +1,8 @@ +CREATE TABLE `task_metadata` ( + `task_id` text PRIMARY KEY NOT NULL, + `pinned_at` text, + `last_viewed_at` text, + `last_activity_at` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); diff --git a/packages/workspace-server/src/db/migrations/meta/0010_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000000..c65f4553e3 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,694 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5c56a975-8960-4c9a-8a8c-3cd4b04fad3e", + "prevId": "6d99dc19-32d0-4955-afe3-a87c3f715375", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_org_project_preferences": { + "name": "auth_org_project_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_org_project_account_region_org_idx": { + "name": "auth_org_project_account_region_org_idx", + "columns": ["account_key", "cloud_region", "org_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_org_id": { + "name": "last_selected_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "default_additional_directories": { + "name": "default_additional_directories", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_metadata": { + "name": "task_metadata", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additional_directories": { + "name": "additional_directories", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_state": { + "name": "pr_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/_journal.json b/packages/workspace-server/src/db/migrations/meta/_journal.json index 728968051e..b332cd20e1 100644 --- a/packages/workspace-server/src/db/migrations/meta/_journal.json +++ b/packages/workspace-server/src/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1779747695689, "tag": "0009_bake_default_directories", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1782258190575, + "tag": "0010_wet_norrin_radd", + "breakpoints": true } ] } diff --git a/packages/workspace-server/src/db/repositories.module.ts b/packages/workspace-server/src/db/repositories.module.ts index e1e44629f8..aebcabdbd8 100644 --- a/packages/workspace-server/src/db/repositories.module.ts +++ b/packages/workspace-server/src/db/repositories.module.ts @@ -6,6 +6,7 @@ import { DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, REPOSITORY_REPOSITORY, SUSPENSION_REPOSITORY, + TASK_METADATA_REPOSITORY, WORKSPACE_REPOSITORY, WORKTREE_REPOSITORY, } from "./identifiers"; @@ -15,6 +16,7 @@ import { AuthSessionRepository } from "./repositories/auth-session-repository"; import { DefaultAdditionalDirectoryRepository } from "./repositories/default-additional-directory-repository"; import { RepositoryRepository } from "./repositories/repository-repository"; import { SuspensionRepositoryImpl } from "./repositories/suspension-repository"; +import { TaskMetadataRepository } from "./repositories/task-metadata-repository"; import { WorkspaceRepository } from "./repositories/workspace-repository"; import { WorktreeRepository } from "./repositories/worktree-repository"; @@ -31,4 +33,5 @@ export const repositoriesModule = new ContainerModule(({ bind }) => { bind(DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY) .to(DefaultAdditionalDirectoryRepository) .inSingletonScope(); + bind(TASK_METADATA_REPOSITORY).to(TaskMetadataRepository).inSingletonScope(); }); diff --git a/packages/workspace-server/src/db/repositories/task-metadata-repository.mock.ts b/packages/workspace-server/src/db/repositories/task-metadata-repository.mock.ts new file mode 100644 index 0000000000..b010269b5b --- /dev/null +++ b/packages/workspace-server/src/db/repositories/task-metadata-repository.mock.ts @@ -0,0 +1,43 @@ +import type { + ITaskMetadataRepository, + TaskMetadataPatch, + TaskMetadataRow, +} from "./task-metadata-repository"; + +export interface MockTaskMetadataRepository extends ITaskMetadataRepository { + _rows: Map; +} + +export function createMockTaskMetadataRepository(): MockTaskMetadataRepository { + const rows = new Map(); + const apply = (taskId: string, patch: TaskMetadataPatch) => { + const ts = new Date().toISOString(); + const existing = rows.get(taskId); + // Mirror the SQL upsert: only keys present in the patch are written, so an + // explicit `null` (e.g. unpin) clears the field rather than being ignored. + rows.set(taskId, { + taskId, + pinnedAt: + "pinnedAt" in patch + ? (patch.pinnedAt ?? null) + : (existing?.pinnedAt ?? null), + lastViewedAt: + "lastViewedAt" in patch + ? (patch.lastViewedAt ?? null) + : (existing?.lastViewedAt ?? null), + lastActivityAt: + "lastActivityAt" in patch + ? (patch.lastActivityAt ?? null) + : (existing?.lastActivityAt ?? null), + createdAt: existing?.createdAt ?? ts, + updatedAt: ts, + }); + }; + return { + _rows: rows, + findByTaskId: (taskId) => rows.get(taskId) ?? null, + findAll: () => [...rows.values()], + findAllPinned: () => [...rows.values()].filter((r) => r.pinnedAt != null), + upsert: apply, + }; +} diff --git a/packages/workspace-server/src/db/repositories/task-metadata-repository.ts b/packages/workspace-server/src/db/repositories/task-metadata-repository.ts new file mode 100644 index 0000000000..47a4234d08 --- /dev/null +++ b/packages/workspace-server/src/db/repositories/task-metadata-repository.ts @@ -0,0 +1,75 @@ +import { eq, isNotNull } from "drizzle-orm"; +import { inject, injectable } from "inversify"; +import { DATABASE_SERVICE } from "../identifiers"; +import { taskMetadata } from "../schema"; +import type { DatabaseService } from "../service"; + +export type TaskMetadataRow = typeof taskMetadata.$inferSelect; + +const now = () => new Date().toISOString(); + +/** Fields that can be set on a task-metadata upsert. */ +export interface TaskMetadataPatch { + pinnedAt?: string | null; + lastViewedAt?: string | null; + lastActivityAt?: string | null; +} + +/** + * Pin / view / activity metadata for tasks that have no `workspaces` row + * (repo-less channel tasks whose working dir is a scratch dir). Keyed by task + * id so the per-device viewed/pinned state persists across reload, just like it + * does for tasks that own a workspace row. + */ +export interface ITaskMetadataRepository { + findByTaskId(taskId: string): TaskMetadataRow | null; + findAll(): TaskMetadataRow[]; + findAllPinned(): TaskMetadataRow[]; + upsert(taskId: string, patch: TaskMetadataPatch): void; +} + +@injectable() +export class TaskMetadataRepository implements ITaskMetadataRepository { + constructor( + @inject(DATABASE_SERVICE) + private readonly databaseService: DatabaseService, + ) {} + + private get db() { + return this.databaseService.db; + } + + findByTaskId(taskId: string): TaskMetadataRow | null { + return ( + this.db + .select() + .from(taskMetadata) + .where(eq(taskMetadata.taskId, taskId)) + .get() ?? null + ); + } + + findAll(): TaskMetadataRow[] { + return this.db.select().from(taskMetadata).all(); + } + + findAllPinned(): TaskMetadataRow[] { + return this.db + .select() + .from(taskMetadata) + .where(isNotNull(taskMetadata.pinnedAt)) + .all(); + } + + upsert(taskId: string, patch: TaskMetadataPatch): void { + const timestamp = now(); + this.db + .insert(taskMetadata) + .values({ taskId, ...patch, createdAt: timestamp, updatedAt: timestamp }) + .onConflictDoUpdate({ + target: taskMetadata.taskId, + set: { ...patch, updatedAt: timestamp }, + }) + .run(); + } +} diff --git a/packages/workspace-server/src/db/schema.ts b/packages/workspace-server/src/db/schema.ts index dad389741e..4b77aa5506 100644 --- a/packages/workspace-server/src/db/schema.ts +++ b/packages/workspace-server/src/db/schema.ts @@ -43,6 +43,20 @@ export const workspaces = sqliteTable( (t) => [index("workspaces_repository_id_idx").on(t.repositoryId)], ); +// Pin / view / activity metadata for tasks that have no `workspaces` row — +// repo-less channel tasks (e.g. canvas generation) whose working dir is a +// scratch dir, not a tracked workspace. Tasks WITH a workspace row keep this +// metadata on their workspace row; this table is the fallback home so the +// per-device viewed/pinned state survives reload for the rowless ones too. +export const taskMetadata = sqliteTable("task_metadata", { + taskId: text().primaryKey(), + pinnedAt: text(), + lastViewedAt: text(), + lastActivityAt: text(), + createdAt: createdAt(), + updatedAt: updatedAt(), +}); + export const worktrees = sqliteTable("worktrees", { id: id(), workspaceId: text() diff --git a/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts index 71dea7bcb9..ecdebc84a7 100644 --- a/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts +++ b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.test.ts @@ -1,4 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createMockTaskMetadataRepository, + type MockTaskMetadataRepository, +} from "../../db/repositories/task-metadata-repository.mock"; import { WorkspaceMetadataService } from "./workspace-metadata"; const NOW_ISO = "2026-01-01T00:00:00.000Z"; @@ -6,14 +10,19 @@ const NOW_ISO = "2026-01-01T00:00:00.000Z"; function createService() { const repo = { findByTaskId: vi.fn(), - findAll: vi.fn(), - findAllPinned: vi.fn(), + findAll: vi.fn().mockReturnValue([]), + findAllPinned: vi.fn().mockReturnValue([]), updatePinnedAt: vi.fn(), updateLastViewedAt: vi.fn(), updateLastActivityAt: vi.fn(), }; - const service = new WorkspaceMetadataService(repo as never); - return { service, repo }; + const metadataRepo: MockTaskMetadataRepository = + createMockTaskMetadataRepository(); + const service = new WorkspaceMetadataService( + repo as never, + metadataRepo as never, + ); + return { service, repo, metadataRepo }; } beforeEach(() => { @@ -26,17 +35,6 @@ afterEach(() => { }); describe("WorkspaceMetadataService.togglePin", () => { - it("returns an unpinned result and updates nothing when the workspace is missing", () => { - const { service, repo } = createService(); - repo.findByTaskId.mockReturnValue(undefined); - - expect(service.togglePin("t1")).toEqual({ - isPinned: false, - pinnedAt: null, - }); - expect(repo.updatePinnedAt).not.toHaveBeenCalled(); - }); - it("pins an unpinned workspace with the current timestamp", () => { const { service, repo } = createService(); repo.findByTaskId.mockReturnValue({ taskId: "t1", pinnedAt: null }); @@ -61,13 +59,46 @@ describe("WorkspaceMetadataService.togglePin", () => { }); expect(repo.updatePinnedAt).toHaveBeenCalledWith("t1", null); }); + + it("pins a rowless task via the task_metadata table", () => { + const { service, repo, metadataRepo } = createService(); + repo.findByTaskId.mockReturnValue(undefined); + + expect(service.togglePin("t1")).toEqual({ + isPinned: true, + pinnedAt: NOW_ISO, + }); + expect(repo.updatePinnedAt).not.toHaveBeenCalled(); + expect(metadataRepo.findByTaskId("t1")?.pinnedAt).toBe(NOW_ISO); + + // Toggling again unpins it. + expect(service.togglePin("t1")).toEqual({ + isPinned: false, + pinnedAt: null, + }); + expect(metadataRepo.findByTaskId("t1")?.pinnedAt).toBeNull(); + }); }); describe("WorkspaceMetadataService.markViewed", () => { - it("records the current time as the last viewed timestamp", () => { - const { service, repo } = createService(); + it("records the current time on the workspace row when one exists", () => { + const { service, repo, metadataRepo } = createService(); + repo.findByTaskId.mockReturnValue({ taskId: "t1" }); + service.markViewed("t1"); + expect(repo.updateLastViewedAt).toHaveBeenCalledWith("t1", NOW_ISO); + expect(metadataRepo.findByTaskId("t1")).toBeNull(); + }); + + it("records the view in task_metadata for a rowless task", () => { + const { service, repo, metadataRepo } = createService(); + repo.findByTaskId.mockReturnValue(undefined); + + service.markViewed("t1"); + + expect(repo.updateLastViewedAt).not.toHaveBeenCalled(); + expect(metadataRepo.findByTaskId("t1")?.lastViewedAt).toBe(NOW_ISO); }); }); @@ -95,25 +126,27 @@ describe("WorkspaceMetadataService.markActivity", () => { expect(repo.updateLastActivityAt).toHaveBeenCalledWith("t1", expected); }); - it("falls back to the current time when there is no last viewed time", () => { - const { service, repo } = createService(); - repo.findByTaskId.mockReturnValue({ taskId: "t1", lastViewedAt: null }); + it("records activity in task_metadata for a rowless task", () => { + const { service, repo, metadataRepo } = createService(); + repo.findByTaskId.mockReturnValue(undefined); service.markActivity("t1"); - expect(repo.updateLastActivityAt).toHaveBeenCalledWith("t1", NOW_ISO); + expect(repo.updateLastActivityAt).not.toHaveBeenCalled(); + expect(metadataRepo.findByTaskId("t1")?.lastActivityAt).toBe(NOW_ISO); }); }); describe("WorkspaceMetadataService projections", () => { - it("returns the task ids of all pinned workspaces", () => { - const { service, repo } = createService(); + it("unions pinned task ids from workspaces and task_metadata", () => { + const { service, repo, metadataRepo } = createService(); repo.findAllPinned.mockReturnValue([{ taskId: "a" }, { taskId: "b" }]); + metadataRepo.upsert("c", { pinnedAt: NOW_ISO }); - expect(service.getPinnedTaskIds()).toEqual(["a", "b"]); + expect(service.getPinnedTaskIds()).toEqual(["a", "b", "c"]); }); - it("projects the timestamps for a task, defaulting missing values to null", () => { + it("projects timestamps from the workspace row when present", () => { const { service, repo } = createService(); repo.findByTaskId.mockReturnValue({ taskId: "t1", @@ -129,6 +162,18 @@ describe("WorkspaceMetadataService projections", () => { }); }); + it("falls back to task_metadata for a rowless task", () => { + const { service, repo, metadataRepo } = createService(); + repo.findByTaskId.mockReturnValue(undefined); + metadataRepo.upsert("t1", { lastViewedAt: NOW_ISO }); + + expect(service.getTaskTimestamps("t1")).toEqual({ + pinnedAt: null, + lastViewedAt: NOW_ISO, + lastActivityAt: null, + }); + }); + it("returns all-null timestamps for an unknown task", () => { const { service, repo } = createService(); repo.findByTaskId.mockReturnValue(undefined); @@ -140,19 +185,16 @@ describe("WorkspaceMetadataService projections", () => { }); }); - it("builds a record of timestamps keyed by task id", () => { - const { service, repo } = createService(); + it("merges all timestamps, with workspace rows winning on overlap", () => { + const { service, repo, metadataRepo } = createService(); repo.findAll.mockReturnValue([ - { - taskId: "a", - pinnedAt: "p", - lastViewedAt: "v", - lastActivityAt: "x", - }, + { taskId: "a", pinnedAt: "p", lastViewedAt: "v", lastActivityAt: "x" }, ]); + metadataRepo.upsert("b", { lastViewedAt: "bv" }); expect(service.getAllTaskTimestamps()).toEqual({ a: { pinnedAt: "p", lastViewedAt: "v", lastActivityAt: "x" }, + b: { pinnedAt: null, lastViewedAt: "bv", lastActivityAt: null }, }); }); }); diff --git a/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts index 3cb8f14b9b..e7eb2b3f9d 100644 --- a/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts +++ b/packages/workspace-server/src/services/workspace-metadata/workspace-metadata.ts @@ -1,5 +1,9 @@ import { inject, injectable } from "inversify"; -import { WORKSPACE_REPOSITORY } from "../../db/identifiers"; +import { + TASK_METADATA_REPOSITORY, + WORKSPACE_REPOSITORY, +} from "../../db/identifiers"; +import type { ITaskMetadataRepository } from "../../db/repositories/task-metadata-repository"; import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; export interface TaskTimestamps { @@ -12,56 +16,92 @@ export interface TaskTimestamps { * Pin / view / activity metadata for tasks — pure projections over the * Workspace records. Extracted from the monolithic WorkspaceService so these * data operations live next to the repository, with no git/fs/orchestration. + * + * A task that owns a `workspaces` row keeps its metadata on that row (unchanged + * behavior). Repo-less channel tasks (e.g. canvas generation) have no workspace + * row — their working dir is a scratch dir — so their metadata lives in the + * dedicated `task_metadata` table instead. Without it, `markViewed` would write + * to zero rows and the viewed state would be forgotten on reload. */ @injectable() export class WorkspaceMetadataService { constructor( @inject(WORKSPACE_REPOSITORY) private readonly workspaceRepo: IWorkspaceRepository, + @inject(TASK_METADATA_REPOSITORY) + private readonly taskMetadataRepo: ITaskMetadataRepository, ) {} togglePin(taskId: string): { isPinned: boolean; pinnedAt: string | null } { const workspace = this.workspaceRepo.findByTaskId(taskId); - if (!workspace) { - return { isPinned: false, pinnedAt: null }; + if (workspace) { + const newPinnedAt = workspace.pinnedAt ? null : new Date().toISOString(); + this.workspaceRepo.updatePinnedAt(taskId, newPinnedAt); + return { isPinned: newPinnedAt !== null, pinnedAt: newPinnedAt }; } - const newPinnedAt = workspace.pinnedAt ? null : new Date().toISOString(); - this.workspaceRepo.updatePinnedAt(taskId, newPinnedAt); + // Rowless task: fall back to the task_metadata table. + const existing = this.taskMetadataRepo.findByTaskId(taskId); + const newPinnedAt = existing?.pinnedAt ? null : new Date().toISOString(); + this.taskMetadataRepo.upsert(taskId, { pinnedAt: newPinnedAt }); return { isPinned: newPinnedAt !== null, pinnedAt: newPinnedAt }; } markViewed(taskId: string): void { - this.workspaceRepo.updateLastViewedAt(taskId, new Date().toISOString()); + const lastViewedAt = new Date().toISOString(); + if (this.workspaceRepo.findByTaskId(taskId)) { + this.workspaceRepo.updateLastViewedAt(taskId, lastViewedAt); + return; + } + this.taskMetadataRepo.upsert(taskId, { lastViewedAt }); } markActivity(taskId: string): void { const workspace = this.workspaceRepo.findByTaskId(taskId); - const lastViewedAt = workspace?.lastViewedAt - ? new Date(workspace.lastViewedAt).getTime() + const metadata = workspace ?? this.taskMetadataRepo.findByTaskId(taskId); + const lastViewedAt = metadata?.lastViewedAt + ? new Date(metadata.lastViewedAt).getTime() : 0; const now = Date.now(); + // Activity must read as newer than the last view, or an unread task that + // the user is actively running would never surface as unread. const activityTime = Math.max(now, lastViewedAt + 1); - this.workspaceRepo.updateLastActivityAt( - taskId, - new Date(activityTime).toISOString(), - ); + const lastActivityAt = new Date(activityTime).toISOString(); + if (workspace) { + this.workspaceRepo.updateLastActivityAt(taskId, lastActivityAt); + return; + } + this.taskMetadataRepo.upsert(taskId, { lastActivityAt }); } getPinnedTaskIds(): string[] { - return this.workspaceRepo.findAllPinned().map((w) => w.taskId); + return [ + ...this.workspaceRepo.findAllPinned().map((w) => w.taskId), + ...this.taskMetadataRepo.findAllPinned().map((m) => m.taskId), + ]; } getTaskTimestamps(taskId: string): TaskTimestamps { - const workspace = this.workspaceRepo.findByTaskId(taskId); + const row = + this.workspaceRepo.findByTaskId(taskId) ?? + this.taskMetadataRepo.findByTaskId(taskId); return { - pinnedAt: workspace?.pinnedAt ?? null, - lastViewedAt: workspace?.lastViewedAt ?? null, - lastActivityAt: workspace?.lastActivityAt ?? null, + pinnedAt: row?.pinnedAt ?? null, + lastViewedAt: row?.lastViewedAt ?? null, + lastActivityAt: row?.lastActivityAt ?? null, }; } getAllTaskTimestamps(): Record { const result: Record = {}; + // Rowless metadata first; workspace rows win on the (unexpected) overlap of + // a task that later gained a workspace row. + for (const m of this.taskMetadataRepo.findAll()) { + result[m.taskId] = { + pinnedAt: m.pinnedAt, + lastViewedAt: m.lastViewedAt, + lastActivityAt: m.lastActivityAt, + }; + } for (const w of this.workspaceRepo.findAll()) { result[w.taskId] = { pinnedAt: w.pinnedAt,