From e129c1fef061f3abc71a2b9f8a7b1795393872c0 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Mon, 18 May 2026 14:34:51 +0200 Subject: [PATCH] feat(workspace): add last_user_message_at field for better activity tracking Generated-By: PostHog Code Task-Id: bd0d1144-27e7-4128-8a7a-13f8fa183458 --- .../db/migrations/0006_chilly_titania.sql | 3 + .../db/migrations/meta/0006_snapshot.json | 533 ++++++++++++++++++ .../src/main/db/migrations/meta/_journal.json | 7 + .../repositories/workspace-repository.mock.ts | 2 + .../db/repositories/workspace-repository.ts | 23 + apps/code/src/main/db/schema.ts | 1 + .../src/main/services/workspace/schemas.ts | 6 + apps/code/src/main/trpc/routers/workspace.ts | 34 +- .../components/CommandCenterView.tsx | 2 +- .../sessions/hooks/useSessionCallbacks.ts | 9 +- .../sidebar/components/SidebarMenu.tsx | 4 +- .../sidebar/components/TaskListView.tsx | 4 +- .../features/sidebar/hooks/useSidebarData.ts | 5 +- .../sidebar/hooks/useTaskPrStatus.test.ts | 1 + .../features/sidebar/hooks/useTaskViewed.ts | 185 +++--- .../src/renderer/hooks/useTaskDeepLink.ts | 2 +- 16 files changed, 690 insertions(+), 131 deletions(-) create mode 100644 apps/code/src/main/db/migrations/0006_chilly_titania.sql create mode 100644 apps/code/src/main/db/migrations/meta/0006_snapshot.json diff --git a/apps/code/src/main/db/migrations/0006_chilly_titania.sql b/apps/code/src/main/db/migrations/0006_chilly_titania.sql new file mode 100644 index 000000000..85ae31f33 --- /dev/null +++ b/apps/code/src/main/db/migrations/0006_chilly_titania.sql @@ -0,0 +1,3 @@ +ALTER TABLE `workspaces` ADD `last_user_message_at` text; +--> statement-breakpoint +UPDATE `workspaces` SET `last_user_message_at` = `last_activity_at` WHERE `last_activity_at` IS NOT NULL; \ No newline at end of file diff --git a/apps/code/src/main/db/migrations/meta/0006_snapshot.json b/apps/code/src/main/db/migrations/meta/0006_snapshot.json new file mode 100644 index 000000000..5c4f665a7 --- /dev/null +++ b/apps/code/src/main/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,533 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f2912abb-9876-474a-9afb-74f5165af01e", + "prevId": "b530fcd1-77cc-4df0-ad3c-148ee9d5c46b", + "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_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 + }, + "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": {} + }, + "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": {} + }, + "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 + }, + "last_user_message_at": { + "name": "last_user_message_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": { + "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/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json index 5ea0be65d..8da787e57 100644 --- a/apps/code/src/main/db/migrations/meta/_journal.json +++ b/apps/code/src/main/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1775755977659, "tag": "0005_youthful_scarlet_spider", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1779105698998, + "tag": "0006_chilly_titania", + "breakpoints": true } ] } diff --git a/apps/code/src/main/db/repositories/workspace-repository.mock.ts b/apps/code/src/main/db/repositories/workspace-repository.mock.ts index 7be3ade37..7cfab9953 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.mock.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.mock.ts @@ -41,6 +41,7 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { pinnedAt: null, lastViewedAt: null, lastActivityAt: null, + lastUserMessageAt: null, linkedBranch: null, createdAt: now, updatedAt: now, @@ -67,6 +68,7 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { updatePinnedAt: () => {}, updateLastViewedAt: () => {}, updateLastActivityAt: () => {}, + markUserSend: () => {}, updateMode: () => {}, setModeAndRepository: (taskId, mode, repositoryId) => { const id = taskIndex.get(taskId); diff --git a/apps/code/src/main/db/repositories/workspace-repository.ts b/apps/code/src/main/db/repositories/workspace-repository.ts index 6dfcb391f..4d426c7da 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.ts @@ -26,6 +26,14 @@ export interface IWorkspaceRepository { updatePinnedAt(taskId: string, pinnedAt: string | null): void; updateLastViewedAt(taskId: string, lastViewedAt: string): void; updateLastActivityAt(taskId: string, lastActivityAt: string): void; + markUserSend( + taskId: string, + timestamps: { + lastViewedAt: string; + lastActivityAt: string; + lastUserMessageAt: string; + }, + ): void; updateLinkedBranch(taskId: string, linkedBranch: string | null): void; updateMode(taskId: string, mode: WorkspaceMode): void; setModeAndRepository( @@ -130,6 +138,21 @@ export class WorkspaceRepository implements IWorkspaceRepository { .run(); } + markUserSend( + taskId: string, + timestamps: { + lastViewedAt: string; + lastActivityAt: string; + lastUserMessageAt: string; + }, + ): void { + this.db + .update(workspaces) + .set({ ...timestamps, updatedAt: now() }) + .where(byTaskId(taskId)) + .run(); + } + updateLinkedBranch(taskId: string, linkedBranch: string | null): void { this.db .update(workspaces) diff --git a/apps/code/src/main/db/schema.ts b/apps/code/src/main/db/schema.ts index 8e4f14404..e1f2f8455 100644 --- a/apps/code/src/main/db/schema.ts +++ b/apps/code/src/main/db/schema.ts @@ -31,6 +31,7 @@ export const workspaces = sqliteTable( pinnedAt: text(), lastViewedAt: text(), lastActivityAt: text(), + lastUserMessageAt: text(), createdAt: createdAt(), updatedAt: updatedAt(), }, diff --git a/apps/code/src/main/services/workspace/schemas.ts b/apps/code/src/main/services/workspace/schemas.ts index 79cafc1c0..ca23a7bd4 100644 --- a/apps/code/src/main/services/workspace/schemas.ts +++ b/apps/code/src/main/services/workspace/schemas.ts @@ -217,6 +217,10 @@ export const markActivityInput = z.object({ taskId: z.string(), }); +export const markUserSendInput = z.object({ + taskId: z.string(), +}); + export const getPinnedTaskIdsOutput = z.array(z.string()); export const getTaskTimestampsInput = z.object({ @@ -227,6 +231,7 @@ export const getTaskTimestampsOutput = z.object({ pinnedAt: z.string().nullable(), lastViewedAt: z.string().nullable(), lastActivityAt: z.string().nullable(), + lastUserMessageAt: z.string().nullable(), }); export const getAllTaskTimestampsOutput = z.record( @@ -235,6 +240,7 @@ export const getAllTaskTimestampsOutput = z.record( pinnedAt: z.string().nullable(), lastViewedAt: z.string().nullable(), lastActivityAt: z.string().nullable(), + lastUserMessageAt: z.string().nullable(), }), ); diff --git a/apps/code/src/main/trpc/routers/workspace.ts b/apps/code/src/main/trpc/routers/workspace.ts index 97f44604f..fa5bd57a5 100644 --- a/apps/code/src/main/trpc/routers/workspace.ts +++ b/apps/code/src/main/trpc/routers/workspace.ts @@ -1,3 +1,4 @@ +import type { z } from "zod"; import type { WorkspaceRepository } from "../../db/repositories/workspace-repository"; import { container } from "../../di/container"; import { MAIN_TOKENS } from "../../di/tokens"; @@ -26,6 +27,7 @@ import { listGitWorktreesInput, listGitWorktreesOutput, markActivityInput, + markUserSendInput, markViewedInput, taskPrStatusInput, taskPrStatusOutput, @@ -156,6 +158,24 @@ export const workspaceRouter = router({ ); }), + markUserSend: publicProcedure + .input(markUserSendInput) + .mutation(({ input }) => { + const repo = getWorkspaceRepo(); + const workspace = repo.findByTaskId(input.taskId); + const lastViewedAtMs = workspace?.lastViewedAt + ? new Date(workspace.lastViewedAt).getTime() + : 0; + const nowMs = Date.now(); + const activityMs = Math.max(nowMs, lastViewedAtMs + 1); + const nowIso = new Date(nowMs).toISOString(); + repo.markUserSend(input.taskId, { + lastViewedAt: nowIso, + lastActivityAt: new Date(activityMs).toISOString(), + lastUserMessageAt: nowIso, + }); + }), + getPinnedTaskIds: publicProcedure.output(getPinnedTaskIdsOutput).query(() => { const repo = getWorkspaceRepo(); return repo.findAllPinned().map((w) => w.taskId); @@ -171,6 +191,7 @@ export const workspaceRouter = router({ pinnedAt: workspace?.pinnedAt ?? null, lastViewedAt: workspace?.lastViewedAt ?? null, lastActivityAt: workspace?.lastActivityAt ?? null, + lastUserMessageAt: workspace?.lastUserMessageAt ?? null, }; }), @@ -178,20 +199,13 @@ export const workspaceRouter = router({ .output(getAllTaskTimestampsOutput) .query(() => { const repo = getWorkspaceRepo(); - const workspaces = repo.findAll(); - const result: Record< - string, - { - pinnedAt: string | null; - lastViewedAt: string | null; - lastActivityAt: string | null; - } - > = {}; - for (const w of workspaces) { + const result: z.infer = {}; + for (const w of repo.findAll()) { result[w.taskId] = { pinnedAt: w.pinnedAt, lastViewedAt: w.lastViewedAt, lastActivityAt: w.lastActivityAt, + lastUserMessageAt: w.lastUserMessageAt, }; } return result; diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterView.tsx b/apps/code/src/renderer/features/command-center/components/CommandCenterView.tsx index 2c1853149..b8db83460 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterView.tsx +++ b/apps/code/src/renderer/features/command-center/components/CommandCenterView.tsx @@ -21,7 +21,7 @@ export function CommandCenterView() { useEffect(() => { if (!visibleTaskIdsKey) return; for (const taskId of visibleTaskIdsKey.split(",")) { - markAsViewed(taskId); + markAsViewed({ taskId }); } }, [visibleTaskIdsKey, markAsViewed]); diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts index ae236342f..64b668a9b 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts @@ -30,7 +30,7 @@ export function useSessionCallbacks({ session, repoPath, }: UseSessionCallbacksOptions) { - const { markActivity, markAsViewed } = useTaskViewed(); + const { markAsViewed, markUserSend } = useTaskViewed(); const { requestFocus, setPendingContent } = useDraftStore((s) => s.actions); const sessionRef = useRef(session); @@ -55,15 +55,14 @@ export function useSessionCallbacks({ if (handled) return; try { - markAsViewed(taskId); - markActivity(taskId); + markUserSend({ taskId }); await getSessionService().sendPrompt(taskId, text); const view = useNavigationStore.getState().view; const isViewingTask = view?.type === "task-detail" && view?.data?.id === taskId; if (isViewingTask) { - markAsViewed(taskId); + markAsViewed({ taskId }); } } catch (error) { const message = @@ -72,7 +71,7 @@ export function useSessionCallbacks({ log.error("Failed to send prompt", error); } }, - [taskId, repoPath, markActivity, markAsViewed, task.latest_run], + [taskId, repoPath, markAsViewed, markUserSend, task.latest_run], ); const handleCancelPrompt = useCallback(async () => { diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 8edd66f80..68d350506 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -111,11 +111,11 @@ function SidebarMenuComponent() { previousTaskIdRef.current && previousTaskIdRef.current !== currentTaskId ) { - markAsViewed(previousTaskIdRef.current); + markAsViewed({ taskId: previousTaskIdRef.current }); } if (currentTaskId) { - markAsViewed(currentTaskId); + markAsViewed({ taskId: currentTaskId }); } previousTaskIdRef.current = currentTaskId; diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 07960d98f..c26a67d4e 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -277,8 +277,8 @@ export function TaskListView({ useSidebarStore.getState().reorderFolders(sourceIndex, targetIndex); }, []); - const timestampKey: "lastActivityAt" | "createdAt" = - sortMode === "updated" ? "lastActivityAt" : "createdAt"; + const timestampKey: "lastUserMessageAt" | "createdAt" = + sortMode === "updated" ? "lastUserMessageAt" : "createdAt"; const dateGroupedTasks = useMemo(() => { const groups: { label: string | null; tasks: TaskData[] }[] = []; diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index c0f166b02..6c6c6b521 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -24,6 +24,7 @@ export interface TaskData { title: string; createdAt: number; lastActivityAt: number; + lastUserMessageAt: number; isGenerating: boolean; isUnread: boolean; isPinned: boolean; @@ -77,7 +78,7 @@ interface UseSidebarDataProps { } function getSortValue(task: TaskData, sortMode: SortMode): number { - return sortMode === "updated" ? task.lastActivityAt : task.createdAt; + return sortMode === "updated" ? task.lastUserMessageAt : task.createdAt; } function sortTasks(tasks: TaskData[], sortMode: SortMode): TaskData[] { @@ -216,6 +217,7 @@ export function useSidebarData({ ? Math.max(apiUpdatedAt, localActivity) : apiUpdatedAt; const createdAt = new Date(task.created_at).getTime(); + const lastUserMessageAt = taskTimestamps?.lastUserMessageAt ?? createdAt; const taskLastViewedAt = taskTimestamps?.lastViewedAt; const isUnread = @@ -231,6 +233,7 @@ export function useSidebarData({ title: task.title, createdAt, lastActivityAt, + lastUserMessageAt, isGenerating: session?.isPromptPending ?? false, isUnread, isPinned: pinnedTaskIds.has(task.id), diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts index 50c3c3633..83bf92da2 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskPrStatus.test.ts @@ -32,6 +32,7 @@ function makeTask(overrides: Partial = {}): TaskData { title: "Test task", createdAt: Date.now(), lastActivityAt: Date.now(), + lastUserMessageAt: Date.now(), isGenerating: false, isUnread: false, isPinned: false, diff --git a/apps/code/src/renderer/features/sidebar/hooks/useTaskViewed.ts b/apps/code/src/renderer/features/sidebar/hooks/useTaskViewed.ts index 2633de31e..f2f42a383 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useTaskViewed.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useTaskViewed.ts @@ -1,22 +1,30 @@ import { trpcClient, useTRPC } from "@renderer/trpc"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useMemo, useRef } from "react"; +import { useMemo } from "react"; interface TaskTimestamps { lastViewedAt: number | null; lastActivityAt: number | null; + lastUserMessageAt: number | null; } -function parseTimestamps( - raw: Record< - string, - { - pinnedAt: string | null; - lastViewedAt: string | null; - lastActivityAt: string | null; - } - >, -): Record { +type RawEntry = { + pinnedAt: string | null; + lastViewedAt: string | null; + lastActivityAt: string | null; + lastUserMessageAt: string | null; +}; + +type Raw = Record; + +const EMPTY_ENTRY: RawEntry = { + pinnedAt: null, + lastViewedAt: null, + lastActivityAt: null, + lastUserMessageAt: null, +}; + +function parseTimestamps(raw: Raw): Record { const result: Record = {}; for (const [taskId, ts] of Object.entries(raw)) { result[taskId] = { @@ -26,19 +34,28 @@ function parseTimestamps( lastActivityAt: ts.lastActivityAt ? new Date(ts.lastActivityAt).getTime() : null, + lastUserMessageAt: ts.lastUserMessageAt + ? new Date(ts.lastUserMessageAt).getTime() + : null, }; } return result; } +function computeBumpedActivity(existing: RawEntry | undefined): string { + const lastViewedMs = existing?.lastViewedAt + ? new Date(existing.lastViewedAt).getTime() + : 0; + return new Date(Math.max(Date.now(), lastViewedMs + 1)).toISOString(); +} + export function useTaskViewed() { - const trpcReact = useTRPC(); + const trpc = useTRPC(); const queryClient = useQueryClient(); - const timestampsQueryKey = - trpcReact.workspace.getAllTaskTimestamps.queryKey(); + const queryKey = trpc.workspace.getAllTaskTimestamps.queryKey(); const { data: rawTimestamps = {}, isLoading } = useQuery( - trpcReact.workspace.getAllTaskTimestamps.queryOptions(undefined, { + trpc.workspace.getAllTaskTimestamps.queryOptions(undefined, { staleTime: 30_000, }), ); @@ -48,111 +65,61 @@ export function useTaskViewed() { [rawTimestamps], ); + const optimisticOptions = ( + patcher: (existing: RawEntry | undefined) => Partial, + ) => ({ + onMutate: async ({ taskId }: { taskId: string }) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + const patch = patcher(previous?.[taskId]); + queryClient.setQueryData(queryKey, (old) => ({ + ...(old ?? {}), + [taskId]: { ...EMPTY_ENTRY, ...old?.[taskId], ...patch }, + })); + return { previous }; + }, + onError: ( + _err: unknown, + _vars: unknown, + ctx: { previous: Raw | undefined } | undefined, + ) => { + if (ctx?.previous) queryClient.setQueryData(queryKey, ctx.previous); + }, + }); + const markViewedMutation = useMutation( - trpcReact.workspace.markViewed.mutationOptions({ - onMutate: async ({ taskId }) => { - await queryClient.cancelQueries({ queryKey: timestampsQueryKey }); - const previous = - queryClient.getQueryData(timestampsQueryKey); - const now = new Date().toISOString(); - queryClient.setQueryData( - timestampsQueryKey, - (old) => { - if (!old) - return { - [taskId]: { - pinnedAt: null, - lastViewedAt: now, - lastActivityAt: null, - }, - }; - return { - ...old, - [taskId]: { ...old[taskId], lastViewedAt: now }, - }; - }, - ); - return { previous }; - }, - onError: (_, __, context) => { - if (context?.previous) { - queryClient.setQueryData(timestampsQueryKey, context.previous); - } - }, - }), + trpc.workspace.markViewed.mutationOptions( + optimisticOptions(() => ({ lastViewedAt: new Date().toISOString() })), + ), ); const markActivityMutation = useMutation( - trpcReact.workspace.markActivity.mutationOptions({ - onMutate: async ({ taskId }) => { - await queryClient.cancelQueries({ queryKey: timestampsQueryKey }); - const previous = - queryClient.getQueryData(timestampsQueryKey); - const existing = previous?.[taskId]; - const lastViewedAt = existing?.lastViewedAt - ? new Date(existing.lastViewedAt).getTime() - : 0; - const now = Date.now(); - const activityTime = Math.max(now, lastViewedAt + 1); - const activityIso = new Date(activityTime).toISOString(); - queryClient.setQueryData( - timestampsQueryKey, - (old) => { - if (!old) - return { - [taskId]: { - pinnedAt: null, - lastViewedAt: null, - lastActivityAt: activityIso, - }, - }; - return { - ...old, - [taskId]: { ...old[taskId], lastActivityAt: activityIso }, - }; - }, - ); - return { previous }; - }, - onError: (_, __, context) => { - if (context?.previous) { - queryClient.setQueryData(timestampsQueryKey, context.previous); - } - }, - }), - ); - - const markViewedMutationRef = useRef(markViewedMutation); - markViewedMutationRef.current = markViewedMutation; - - const markActivityMutationRef = useRef(markActivityMutation); - markActivityMutationRef.current = markActivityMutation; - - const markAsViewed = useCallback((taskId: string) => { - markViewedMutationRef.current.mutate({ taskId }); - }, []); - - const markActivity = useCallback((taskId: string) => { - markActivityMutationRef.current.mutate({ taskId }); - }, []); - - const getLastViewedAt = useCallback( - (taskId: string) => timestamps[taskId]?.lastViewedAt ?? undefined, - [timestamps], + trpc.workspace.markActivity.mutationOptions( + optimisticOptions((existing) => ({ + lastActivityAt: computeBumpedActivity(existing), + })), + ), ); - const getLastActivityAt = useCallback( - (taskId: string) => timestamps[taskId]?.lastActivityAt ?? undefined, - [timestamps], + const markUserSendMutation = useMutation( + trpc.workspace.markUserSend.mutationOptions( + optimisticOptions((existing) => { + const nowIso = new Date().toISOString(); + return { + lastViewedAt: nowIso, + lastActivityAt: computeBumpedActivity(existing), + lastUserMessageAt: nowIso, + }; + }), + ), ); return { timestamps, isLoading, - markAsViewed, - markActivity, - getLastViewedAt, - getLastActivityAt, + markAsViewed: markViewedMutation.mutate, + markActivity: markActivityMutation.mutate, + markUserSend: markUserSendMutation.mutate, }; } diff --git a/apps/code/src/renderer/hooks/useTaskDeepLink.ts b/apps/code/src/renderer/hooks/useTaskDeepLink.ts index 73c0b101d..2a403998e 100644 --- a/apps/code/src/renderer/hooks/useTaskDeepLink.ts +++ b/apps/code/src/renderer/hooks/useTaskDeepLink.ts @@ -73,7 +73,7 @@ export function useTaskDeepLink() { // Invalidate to ensure sync with server queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); - markAsViewed(taskId); + markAsViewed({ taskId }); navigateToTask(task); log.info(