diff --git a/wework/src/components/layout/DesktopSidebar.tsx b/wework/src/components/layout/DesktopSidebar.tsx index 11b7b22f14..9d0cb44a84 100644 --- a/wework/src/components/layout/DesktopSidebar.tsx +++ b/wework/src/components/layout/DesktopSidebar.tsx @@ -22,6 +22,7 @@ import { ProjectCreateDialog } from '@/components/projects/ProjectCreateDialog' import { ProjectFolderIcon } from '@/components/projects/ProjectFolderIcon' import { useTranslation } from '@/hooks/useTranslation' import { getPreferredStandaloneDeviceId } from '@/lib/device-selection' +import { selectStandaloneConversations } from '@/lib/taskLists' import { cn } from '@/lib/utils' import { isTauriRuntime } from '@/lib/runtime-environment' import type { @@ -271,14 +272,6 @@ function sortProjectTasks(tasks: ProjectTask[] = []) { }) } -function sortTasksByTime(tasks: Task[] = []) { - return [...tasks].sort((left, right) => { - const leftTime = new Date(left.updated_at || left.created_at || 0).getTime() - const rightTime = new Date(right.updated_at || right.created_at || 0).getTime() - return rightTime - leftTime - }) -} - function isGitWorktreeSession( task: Pick, ): boolean { @@ -873,7 +866,7 @@ export function DesktopSidebar({ [expandedTaskListIds, projects], ) const sortedRecentTasks = useMemo( - () => sortTasksByTime(recentTasks).filter(task => !task.project_id), + () => selectStandaloneConversations(recentTasks), [recentTasks] ) const currentProjectWithTask = useMemo( diff --git a/wework/src/components/layout/MobileDrawer.tsx b/wework/src/components/layout/MobileDrawer.tsx index 2f94eec700..8316f3972e 100644 --- a/wework/src/components/layout/MobileDrawer.tsx +++ b/wework/src/components/layout/MobileDrawer.tsx @@ -13,6 +13,7 @@ import { import { useMemo, useRef, useState } from 'react' import { ProjectCreateDialog } from '@/components/projects/ProjectCreateDialog' import { useTranslation } from '@/hooks/useTranslation' +import { selectStandaloneConversations } from '@/lib/taskLists' import type { CreateGitWorkspaceProjectRequest, CreateProjectRequest, @@ -99,14 +100,6 @@ function sortProjectTasks(tasks: ProjectTask[] = []) { }) } -function sortTasksByTime(tasks: Task[] = []) { - return [...tasks].sort((left, right) => { - const leftTime = new Date(left.updated_at || left.created_at || 0).getTime() - const rightTime = new Date(right.updated_at || right.created_at || 0).getTime() - return rightTime - leftTime - }) -} - export function MobileDrawer({ open, user, @@ -161,7 +154,7 @@ export function MobileDrawer({ () => new Set(), ) const standaloneRecentTasks = useMemo( - () => sortTasksByTime(recentTasks).filter(task => !task.project_id), + () => selectStandaloneConversations(recentTasks), [recentTasks], ) diff --git a/wework/src/features/workbench/WorkbenchProvider.tsx b/wework/src/features/workbench/WorkbenchProvider.tsx index 472496bd35..302d65195f 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -1163,7 +1163,6 @@ export function WorkbenchProvider({ }) setQueuedSends([]) setGuidanceMessages([]) - writeTaskIdToUrl(taskId) const joinResponse = await resolvedServices.chatStream.joinTask(taskId) if (joinResponse?.streaming) { dispatchMessages({ @@ -1584,8 +1583,12 @@ export function WorkbenchProvider({ } if (!state.currentTask && ack.task_id) { - writeTaskIdToUrl(ack.task_id) const projectId = state.currentProject?.id ?? 0 + const routeProjectId = projectId > 0 ? projectId : undefined + // Navigate to the canonical task route so a freshly created chat shares + // the same URL shape as opening an existing one (path, not ?taskId=). + handledTaskRouteRef.current = getTaskRouteKey(ack.task_id, routeProjectId) + navigateTo(buildTaskRoute({ taskId: ack.task_id, projectId: routeProjectId })) const openedTask: Task = { id: ack.task_id, title: message.substring(0, 100), diff --git a/wework/src/features/workbench/workbenchReducer.test.ts b/wework/src/features/workbench/workbenchReducer.test.ts index 7cab67f434..a6b7124a9e 100644 --- a/wework/src/features/workbench/workbenchReducer.test.ts +++ b/wework/src/features/workbench/workbenchReducer.test.ts @@ -270,4 +270,85 @@ describe('workbenchReducer', () => { expect(cleared.currentProject?.id).toBe(7) expect(cleared.currentTask).toBeNull() }) + + test('keeps a project task out of recent chats when the server reports project_id=0 in both lists', () => { + // Simulates the eventual-consistency window right after creating a project + // chat: the server returns the task inside its project AND in the personal + // (recent) list with project_id still 0. It must appear only in the project. + const refreshed = workbenchReducer(initialWorkbenchState, { + type: 'lists_refreshed', + projects: [ + { + id: 7, + name: 'Repo', + tasks: [ + { + id: 42, + task_id: 42, + task_title: 'Project chat', + task_status: 'RUNNING', + title: 'Project chat', + status: 'RUNNING', + task_type: 'code', + created_at: '2026-05-25T00:00:00.000Z', + }, + ], + }, + ], + devices: [], + recentTasks: [ + { + id: 42, + title: 'Project chat', + status: 'RUNNING', + task_type: 'code', + project_id: 0, + created_at: '2026-05-25T00:00:00.000Z', + }, + ], + }) + + expect(refreshed.recentTasks).toEqual([]) + expect(refreshed.projects[0].tasks).toHaveLength(1) + }) + + test('re-adds a missing current project task and never duplicates it into recent chats', () => { + const selected = workbenchReducer(initialWorkbenchState, { + type: 'project_selected', + project: { id: 7, name: 'Repo', tasks: [] }, + }) + const opened = workbenchReducer(selected, { + type: 'task_opened', + task: { + id: 42, + title: 'Project chat', + status: 'RUNNING', + task_type: 'code', + project_id: 7, + created_at: '2026-05-25T00:00:00.000Z', + }, + }) + // Server hasn't associated the task with the project yet: it's missing from + // the project list and leaks into recentTasks with project_id=0. + const refreshed = workbenchReducer(opened, { + type: 'lists_refreshed', + projects: [{ id: 7, name: 'Repo', tasks: [] }], + devices: [], + recentTasks: [ + { + id: 42, + title: 'Project chat', + status: 'RUNNING', + task_type: 'code', + project_id: 0, + created_at: '2026-05-25T00:00:00.000Z', + }, + ], + }) + + expect(refreshed.recentTasks).toEqual([]) + expect(refreshed.projects[0].tasks).toEqual([ + expect.objectContaining({ task_id: 42 }), + ]) + }) }) diff --git a/wework/src/features/workbench/workbenchReducer.ts b/wework/src/features/workbench/workbenchReducer.ts index b1c92ad74d..9c5cf21d59 100644 --- a/wework/src/features/workbench/workbenchReducer.ts +++ b/wework/src/features/workbench/workbenchReducer.ts @@ -76,6 +76,45 @@ function taskBelongsToProject(task: Task): boolean { return Boolean(task.project_id && task.project_id > 0) } +function collectProjectTaskIds(projects: ProjectWithTasks[]): Set { + const ids = new Set() + for (const project of projects) { + for (const task of project.tasks ?? []) { + ids.add(task.task_id) + } + } + return ids +} + +function removeTaskFromProjects( + projects: ProjectWithTasks[], + taskId: number +): ProjectWithTasks[] { + return projects.map(project => { + if (!project.tasks?.some(task => task.task_id === taskId)) return project + return { + ...project, + tasks: project.tasks.filter(task => task.task_id !== taskId), + } + }) +} + +/** + * Enforce that the "conversations" list (recentTasks) and the "projects" lists + * are mutually exclusive. A task already grouped under a project must never + * leak into recentTasks, even if the server momentarily reports project_id=0 + * for it (eventual consistency right after task creation). + */ +function normalizeListExclusivity(state: WorkbenchState): WorkbenchState { + const projectTaskIds = collectProjectTaskIds(state.projects) + if (projectTaskIds.size === 0) return state + const dedupedRecentTasks = state.recentTasks.filter( + task => !projectTaskIds.has(task.id) + ) + if (dedupedRecentTasks.length === state.recentTasks.length) return state + return { ...state, recentTasks: dedupedRecentTasks } +} + function toProjectTask(task: Task): ProjectTask { return { id: task.id, @@ -115,12 +154,16 @@ function upsertOpenedTask(state: WorkbenchState, task: Task): WorkbenchState { } : project ), + // A project task must not linger in the standalone conversation list. + recentTasks: state.recentTasks.filter(item => item.id !== task.id), } } return { ...state, recentTasks: upsertTask(state.recentTasks, task.id, task, item => item.id), + // A standalone task must not linger in any project bucket. + projects: removeTaskFromProjects(state.projects, task.id), } } @@ -156,7 +199,7 @@ export function workbenchReducer( ): WorkbenchState { switch (action.type) { case 'bootstrapped': - return { + return normalizeListExclusivity({ ...state, user: action.user, defaultTeam: action.defaultTeam, @@ -173,7 +216,7 @@ export function workbenchReducer( : action.standaloneDeviceId, isBootstrapping: false, error: null, - } + }) case 'lists_refreshed': { const refreshedState = { ...state, @@ -192,9 +235,11 @@ export function workbenchReducer( state.currentTask && !workListsIncludeTask(action.projects, action.recentTasks, state.currentTask) ) { - return upsertOpenedTask(refreshedState, state.currentTask) + return normalizeListExclusivity( + upsertOpenedTask(refreshedState, state.currentTask) + ) } - return refreshedState + return normalizeListExclusivity(refreshedState) } case 'devices_refreshed': return { diff --git a/wework/src/lib/taskLists.test.ts b/wework/src/lib/taskLists.test.ts new file mode 100644 index 0000000000..9457000820 --- /dev/null +++ b/wework/src/lib/taskLists.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest' +import type { Task } from '@/types/api' +import { selectStandaloneConversations, sortTasksByTime } from './taskLists' + +function task(overrides: Partial & { id: number }): Task { + return { + title: `Task ${overrides.id}`, + status: 'RUNNING', + task_type: 'code', + created_at: '2026-05-25T00:00:00.000Z', + ...overrides, + } as Task +} + +describe('taskLists', () => { + test('sortTasksByTime orders by most recent activity first', () => { + const sorted = sortTasksByTime([ + task({ id: 1, updated_at: '2026-05-25T00:00:00.000Z' }), + task({ id: 2, updated_at: '2026-05-27T00:00:00.000Z' }), + task({ id: 3, updated_at: '2026-05-26T00:00:00.000Z' }), + ]) + + expect(sorted.map(item => item.id)).toEqual([2, 3, 1]) + }) + + test('selectStandaloneConversations excludes tasks that belong to a project', () => { + const result = selectStandaloneConversations([ + task({ id: 1, project_id: 0, updated_at: '2026-05-25T00:00:00.000Z' }), + task({ id: 2, project_id: 7, updated_at: '2026-05-27T00:00:00.000Z' }), + task({ id: 3, updated_at: '2026-05-26T00:00:00.000Z' }), + ]) + + expect(result.map(item => item.id)).toEqual([3, 1]) + }) +}) diff --git a/wework/src/lib/taskLists.ts b/wework/src/lib/taskLists.ts new file mode 100644 index 0000000000..4b855c4860 --- /dev/null +++ b/wework/src/lib/taskLists.ts @@ -0,0 +1,29 @@ +import type { Task } from '@/types/api' + +/** + * Sort tasks by most-recent activity first, falling back to creation time. + */ +export function sortTasksByTime(tasks: Task[] = []): Task[] { + return [...tasks].sort((left, right) => { + const leftTime = new Date(left.updated_at || left.created_at || 0).getTime() + const rightTime = new Date(right.updated_at || right.created_at || 0).getTime() + return rightTime - leftTime + }) +} + +/** + * Whether a task belongs to a project (and therefore must not appear in the + * standalone conversation list). + */ +export function isStandaloneTask(task: Task): boolean { + return !task.project_id +} + +/** + * Build the standalone "conversations" list: standalone tasks only, sorted by + * recent activity. This is the single source of truth for the partition rule + * shared by the desktop sidebar and the mobile drawer. + */ +export function selectStandaloneConversations(recentTasks: Task[]): Task[] { + return sortTasksByTime(recentTasks).filter(isStandaloneTask) +}