Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions wework/src/components/layout/DesktopSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ProjectTask | Task, 'execution_workspace_source'>,
): boolean {
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 2 additions & 9 deletions wework/src/components/layout/MobileDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -161,7 +154,7 @@ export function MobileDrawer({
() => new Set(),
)
const standaloneRecentTasks = useMemo(
() => sortTasksByTime(recentTasks).filter(task => !task.project_id),
() => selectStandaloneConversations(recentTasks),
[recentTasks],
)

Expand Down
7 changes: 5 additions & 2 deletions wework/src/features/workbench/WorkbenchProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1163,7 +1163,6 @@ export function WorkbenchProvider({
})
setQueuedSends([])
setGuidanceMessages([])
writeTaskIdToUrl(taskId)
const joinResponse = await resolvedServices.chatStream.joinTask(taskId)
if (joinResponse?.streaming) {
dispatchMessages({
Expand Down Expand Up @@ -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),
Expand Down
81 changes: 81 additions & 0 deletions wework/src/features/workbench/workbenchReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
])
})
})
53 changes: 49 additions & 4 deletions wework/src/features/workbench/workbenchReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,45 @@ function taskBelongsToProject(task: Task): boolean {
return Boolean(task.project_id && task.project_id > 0)
}

function collectProjectTaskIds(projects: ProjectWithTasks[]): Set<number> {
const ids = new Set<number>()
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,
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -156,7 +199,7 @@ export function workbenchReducer(
): WorkbenchState {
switch (action.type) {
case 'bootstrapped':
return {
return normalizeListExclusivity({
...state,
user: action.user,
defaultTeam: action.defaultTeam,
Expand All @@ -173,7 +216,7 @@ export function workbenchReducer(
: action.standaloneDeviceId,
isBootstrapping: false,
error: null,
}
})
case 'lists_refreshed': {
const refreshedState = {
...state,
Expand All @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions wework/src/lib/taskLists.test.ts
Original file line number Diff line number Diff line change
@@ -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<Task> & { 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])
})
})
29 changes: 29 additions & 0 deletions wework/src/lib/taskLists.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading