diff --git a/apps/buddy/src/i18n/locales/en-US/tasks.ts b/apps/buddy/src/i18n/locales/en-US/tasks.ts
index 63367393..f06bcc4a 100644
--- a/apps/buddy/src/i18n/locales/en-US/tasks.ts
+++ b/apps/buddy/src/i18n/locales/en-US/tasks.ts
@@ -34,6 +34,7 @@ export default {
'desktop.tasks.deleteTask': 'Delete',
'desktop.tasks.deleteTaskConfirmMessage': '“{title}” will be removed from the task list.',
'desktop.tasks.deleteTaskConfirmTitle': 'Delete task?',
+ 'desktop.tasks.expandRemaining': 'Show {count} more',
'desktop.imagePreview.close': 'Close image preview',
'desktop.imagePreview.download': 'Download image',
'desktop.imagePreview.next': 'Next image',
diff --git a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts
index 64762b64..f8fbd2b8 100644
--- a/apps/buddy/src/i18n/locales/zh-CN/tasks.ts
+++ b/apps/buddy/src/i18n/locales/zh-CN/tasks.ts
@@ -32,6 +32,7 @@ export default {
'desktop.tasks.deleteTask': '删除',
'desktop.tasks.deleteTaskConfirmMessage': '“{title}”将从任务列表中移除。',
'desktop.tasks.deleteTaskConfirmTitle': '删除任务?',
+ 'desktop.tasks.expandRemaining': '展开其余 {count} 个',
'desktop.imagePreview.close': '关闭大图',
'desktop.imagePreview.download': '下载图片',
'desktop.imagePreview.next': '下一张图片',
diff --git a/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskIndex.vue b/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskIndex.vue
index 302e6f3e..fd3ca5ac 100644
--- a/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskIndex.vue
+++ b/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskIndex.vue
@@ -14,6 +14,7 @@ import { useBuddyI18n } from '@/i18n/buddyI18n'
import DesktopSpaceDialog from '@/modules/tasks/widgets/task-index/DesktopSpaceDialog.vue'
import DesktopTaskRow from '@/modules/tasks/widgets/task-index/DesktopTaskRow.vue'
import DesktopTaskSearchDialog from '@/modules/tasks/widgets/task-index/DesktopTaskSearchDialog.vue'
+import DesktopTaskSidebarExpandRow from '@/modules/tasks/widgets/task-index/DesktopTaskSidebarExpandRow.vue'
import DesktopTaskSidebarSection from '@/modules/tasks/widgets/task-index/DesktopTaskSidebarSection.vue'
import DesktopTaskSpaceRow from '@/modules/tasks/widgets/task-index/DesktopTaskSpaceRow.vue'
import {
@@ -80,6 +81,7 @@ const {
dropPinnedItem,
endPinnedDrag,
enterPinnedDropTarget,
+ expandConversationGroup,
getTaskTitle,
getPinnedDropPosition,
isSectionExpanded,
@@ -101,7 +103,7 @@ const {
requestTaskRename,
saveSpace,
selectSpaceMenuAction,
- globalTasks,
+ taskRows,
taskDeleteTarget,
taskRenameTarget,
taskTitleDraft,
@@ -207,6 +209,12 @@ function openSearchSpace(spaceId: string) {
@toggle="toggleSpace(item.space.id)"
/>
+
+
recordScrollAnchor('tasks', index)"
@update:expanded="value => setSectionExpanded('tasks', value)"
>
-
+
+
diff --git a/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskSidebarExpandRow.vue b/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskSidebarExpandRow.vue
new file mode 100644
index 00000000..0ac27bce
--- /dev/null
+++ b/apps/buddy/src/modules/tasks/widgets/task-index/DesktopTaskSidebarExpandRow.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/buddy/src/modules/tasks/widgets/task-index/__tests__/taskPinnedItems.spec.ts b/apps/buddy/src/modules/tasks/widgets/task-index/__tests__/taskPinnedItems.spec.ts
new file mode 100644
index 00000000..d8b45db5
--- /dev/null
+++ b/apps/buddy/src/modules/tasks/widgets/task-index/__tests__/taskPinnedItems.spec.ts
@@ -0,0 +1,135 @@
+import type { LocalConversationSummary } from '@buddy-shared/conversation/conversationApi'
+import type { LocalSpace } from '@buddy-shared/spaces/spaceApi'
+import { describe, expect, it } from 'vitest'
+import {
+ DESKTOP_TASK_SIDEBAR_CONVERSATION_LIMIT,
+ DESKTOP_TASK_SIDEBAR_TASKS_GROUP_KEY,
+ resolveTaskIndexProjection,
+ spaceConversationGroupKey,
+} from '../taskPinnedItems'
+
+function createTask(id: string, spaceId: string | null): LocalConversationSummary {
+ return {
+ id,
+ spaceId,
+ title: id,
+ updatedAt: '2026-09-29T00:00:00.000Z',
+ activity: 'idle',
+ } as LocalConversationSummary
+}
+
+function createSpace(id: string): LocalSpace {
+ return {
+ id,
+ name: id,
+ icon: null,
+ iconColor: null,
+ primaryDirectory: null,
+ revokedAt: null,
+ } as unknown as LocalSpace
+}
+
+describe('taskIndexProjection pinned conversations', () => {
+ it('pins a conversation that belongs to a Space', () => {
+ const projection = resolveTaskIndexProjection({
+ expandedSpaceIds: new Set(['space-a']),
+ pinnedItems: [{ kind: 'conversation', id: 'task-a1' }],
+ spaces: [createSpace('space-a')],
+ tasks: [createTask('task-a1', 'space-a'), createTask('task-a2', 'space-a')],
+ })
+
+ expect(projection.pinnedRows).toEqual([
+ { task: expect.objectContaining({ id: 'task-a1' }), key: 'pinned:conversation:task-a1', kind: 'task', pinKey: 'conversation:task-a1', pinnedTopLevel: true },
+ ])
+ expect(projection.spaceRows.flatMap(row => row.kind === 'task' ? [row.task.id] : [])).toEqual(['task-a2'])
+ })
+
+ it('keeps a pinned Space conversation out of the pinned Space expansion', () => {
+ const projection = resolveTaskIndexProjection({
+ expandedSpaceIds: new Set(['space-a']),
+ pinnedItems: [{ kind: 'space', id: 'space-a' }, { kind: 'conversation', id: 'task-a1' }],
+ spaces: [createSpace('space-a')],
+ tasks: [createTask('task-a1', 'space-a'), createTask('task-a2', 'space-a')],
+ })
+
+ expect(projection.pinnedRows.map(row => row.key)).toEqual([
+ 'pinned:space:space-a',
+ 'pinned:space:space-a:conversation:task-a2',
+ 'pinned:conversation:task-a1',
+ ])
+ })
+
+ it('drops pinned conversations that no longer exist', () => {
+ const projection = resolveTaskIndexProjection({
+ expandedSpaceIds: new Set(['space-a']),
+ pinnedItems: [{ kind: 'conversation', id: 'task-gone' }],
+ spaces: [createSpace('space-a')],
+ tasks: [createTask('task-a1', 'space-a')],
+ })
+
+ expect(projection.pinnedRows).toEqual([])
+ })
+})
+
+describe('taskIndexProjection conversation limit', () => {
+ const tasks = Array.from({ length: 8 }, (_, index) => createTask(`task-${index}`, null))
+
+ it('caps the tasks group and appends an expand row with the remaining count', () => {
+ const projection = resolveTaskIndexProjection({
+ expandedSpaceIds: new Set(),
+ pinnedItems: [],
+ spaces: [],
+ tasks,
+ })
+
+ expect(projection.taskRows).toHaveLength(DESKTOP_TASK_SIDEBAR_CONVERSATION_LIMIT + 1)
+ expect(projection.taskRows.at(-1)).toEqual({
+ groupKey: DESKTOP_TASK_SIDEBAR_TASKS_GROUP_KEY,
+ key: `${DESKTOP_TASK_SIDEBAR_TASKS_GROUP_KEY}:expand`,
+ kind: 'expand',
+ remaining: 8 - DESKTOP_TASK_SIDEBAR_CONVERSATION_LIMIT,
+ })
+ })
+
+ it('shows every conversation once the group is expanded', () => {
+ const projection = resolveTaskIndexProjection({
+ expandedConversationGroups: new Set([DESKTOP_TASK_SIDEBAR_TASKS_GROUP_KEY]),
+ expandedSpaceIds: new Set(),
+ pinnedItems: [],
+ spaces: [],
+ tasks,
+ })
+
+ expect(projection.taskRows).toHaveLength(tasks.length)
+ expect(projection.taskRows.some(row => row.kind === 'expand')).toBe(false)
+ })
+
+ it('caps a Space conversation list independently', () => {
+ const projection = resolveTaskIndexProjection({
+ expandedSpaceIds: new Set(['space-a']),
+ pinnedItems: [],
+ spaces: [createSpace('space-a')],
+ tasks: Array.from({ length: 7 }, (_, index) => createTask(`task-a${index}`, 'space-a')),
+ })
+
+ const taskRows = projection.spaceRows.filter(row => row.kind !== 'space')
+ expect(taskRows).toHaveLength(DESKTOP_TASK_SIDEBAR_CONVERSATION_LIMIT + 1)
+ expect(taskRows.at(-1)).toMatchObject({
+ groupKey: spaceConversationGroupKey('space-a'),
+ kind: 'expand',
+ remaining: 2,
+ })
+ })
+
+ it('does not add an expand row when the group fits the limit', () => {
+ const projection = resolveTaskIndexProjection({
+ expandedSpaceIds: new Set(),
+ pinnedItems: [],
+ spaces: [],
+ tasks: tasks.slice(0, DESKTOP_TASK_SIDEBAR_CONVERSATION_LIMIT),
+ })
+
+ expect(projection.taskRows).toHaveLength(DESKTOP_TASK_SIDEBAR_CONVERSATION_LIMIT)
+ expect(projection.taskRows.some(row => row.kind === 'expand')).toBe(false)
+ })
+})
diff --git a/apps/buddy/src/modules/tasks/widgets/task-index/taskPinnedItems.ts b/apps/buddy/src/modules/tasks/widgets/task-index/taskPinnedItems.ts
index 46b731ce..9f50c87f 100644
--- a/apps/buddy/src/modules/tasks/widgets/task-index/taskPinnedItems.ts
+++ b/apps/buddy/src/modules/tasks/widgets/task-index/taskPinnedItems.ts
@@ -18,22 +18,39 @@ export type TaskIndexRow
pinnedTopLevel?: boolean
spaceTask?: boolean
}
+ | {
+ groupKey: string
+ key: string
+ kind: 'expand'
+ remaining: number
+ }
export interface TaskIndexProjection {
pinnedItems: DesktopTaskPinnedItem[]
pinnedRows: TaskIndexRow[]
spaceRows: TaskIndexRow[]
- globalTasks: LocalConversationSummary[]
+ taskRows: TaskIndexRow[]
}
export type DesktopTaskPinnedDropPosition = 'after' | 'before'
+export const DESKTOP_TASK_SIDEBAR_CONVERSATION_LIMIT = 5
+export const DESKTOP_TASK_SIDEBAR_TASKS_GROUP_KEY = 'tasks'
+
+export function spaceConversationGroupKey(spaceId: string): string {
+ return `space:${spaceId}`
+}
+
export function resolveTaskIndexProjection(input: {
+ conversationLimit?: number
+ expandedConversationGroups?: ReadonlySet
expandedSpaceIds: ReadonlySet
pinnedItems: ReadonlyArray
spaces: ReadonlyArray
tasks: ReadonlyArray
}): TaskIndexProjection {
+ const conversationLimit = input.conversationLimit ?? DESKTOP_TASK_SIDEBAR_CONVERSATION_LIMIT
+ const expandedGroups = input.expandedConversationGroups ?? new Set()
const activeSpaces = input.spaces.filter(space => space.revokedAt === null)
const spacesById = new Map(activeSpaces.map(space => [space.id, space]))
const globalTasks = input.tasks.filter(task => task.spaceId === null)
@@ -84,14 +101,19 @@ export function resolveTaskIndexProjection(input: {
}]
if (!input.expandedSpaceIds.has(space.id))
return rows
- const spaceTasks = tasksBySpace.get(space.id) ?? []
- return rows.concat(spaceTasks.map(task => ({
+ const spaceTasks = (tasksBySpace.get(space.id) ?? [])
+ .filter(task => !pinnedConversationIds.has(task.id))
+ return rows.concat(capConversationRows(spaceTasks.map(task => ({
task,
key: `pinned:${pinKey}:conversation:${task.id}`,
kind: 'task' as const,
pinKey,
spaceTask: true,
- })))
+ })), {
+ expanded: expandedGroups.has(spaceConversationGroupKey(space.id)),
+ groupKey: spaceConversationGroupKey(space.id),
+ limit: conversationLimit,
+ }))
}),
spaceRows: activeSpaces
.filter(space => !pinnedSpaceIds.has(space.id))
@@ -99,11 +121,45 @@ export function resolveTaskIndexProjection(input: {
space,
tasksBySpace.get(space.id) ?? [],
input.expandedSpaceIds.has(space.id),
+ {
+ expanded: expandedGroups.has(spaceConversationGroupKey(space.id)),
+ limit: conversationLimit,
+ },
)),
- globalTasks: globalTasks.filter(task => !pinnedConversationIds.has(task.id)),
+ taskRows: capConversationRows(
+ globalTasks
+ .filter(task => !pinnedConversationIds.has(task.id))
+ .map(task => ({
+ task,
+ key: task.id,
+ kind: 'task' as const,
+ })),
+ {
+ expanded: expandedGroups.has(DESKTOP_TASK_SIDEBAR_TASKS_GROUP_KEY),
+ groupKey: DESKTOP_TASK_SIDEBAR_TASKS_GROUP_KEY,
+ limit: conversationLimit,
+ },
+ ),
}
}
+function capConversationRows(
+ rows: TaskIndexRow[],
+ input: { expanded: boolean, groupKey: string, limit: number },
+): TaskIndexRow[] {
+ if (input.expanded || rows.length <= input.limit)
+ return rows
+ return [
+ ...rows.slice(0, input.limit),
+ {
+ groupKey: input.groupKey,
+ key: `${input.groupKey}:expand`,
+ kind: 'expand',
+ remaining: rows.length - input.limit,
+ },
+ ]
+}
+
export function prependDesktopTaskPinnedItem(
items: ReadonlyArray,
item: DesktopTaskPinnedItem,
@@ -149,6 +205,7 @@ function createSpaceRows(
space: LocalSpace,
tasks: ReadonlyArray,
expanded: boolean,
+ conversationCap: { expanded: boolean, limit: number },
): TaskIndexRow[] {
const rows: TaskIndexRow[] = [{
key: `space:${space.id}`,
@@ -157,12 +214,16 @@ function createSpaceRows(
}]
if (!expanded)
return rows
- return rows.concat(tasks.map(task => ({
+ return rows.concat(capConversationRows(tasks.map(task => ({
task,
key: `space:${space.id}:conversation:${task.id}`,
kind: 'task' as const,
spaceTask: true,
- })))
+ })), {
+ expanded: conversationCap.expanded,
+ groupKey: spaceConversationGroupKey(space.id),
+ limit: conversationCap.limit,
+ }))
}
function resolveVisiblePinnedItems(input: {
diff --git a/apps/buddy/src/modules/tasks/widgets/task-index/useTaskIndexController.ts b/apps/buddy/src/modules/tasks/widgets/task-index/useTaskIndexController.ts
index b1a0fa27..4c312fa1 100644
--- a/apps/buddy/src/modules/tasks/widgets/task-index/useTaskIndexController.ts
+++ b/apps/buddy/src/modules/tasks/widgets/task-index/useTaskIndexController.ts
@@ -9,10 +9,12 @@ import type { DesktopTaskPinnedDropPosition } from '@/modules/tasks/widgets/task
import { useIntervalFn } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import {
+ DESKTOP_TASK_SIDEBAR_TASKS_GROUP_KEY,
prependDesktopTaskPinnedItem,
removeDesktopTaskPinnedItem,
reorderDesktopTaskPinnedItems,
resolveTaskIndexProjection,
+ spaceConversationGroupKey,
} from '@/modules/tasks/widgets/task-index/taskPinnedItems'
import { useTaskIndexManagement } from './useTaskIndexManagement'
@@ -37,7 +39,9 @@ export function useTaskIndexController(options: UseTaskIndexControllerOptions) {
const activeSpaces = computed(() => options.spaces.value.filter(
space => space.revokedAt === null,
))
+ const expandedConversationGroups = shallowRef>(new Set())
const projection = computed(() => resolveTaskIndexProjection({
+ expandedConversationGroups: expandedConversationGroups.value,
expandedSpaceIds: new Set(activeSpaces.value
.filter(space => isSpaceExpanded(space.id))
.map(space => space.id)),
@@ -48,7 +52,7 @@ export function useTaskIndexController(options: UseTaskIndexControllerOptions) {
const pinnedItems = computed(() => projection.value.pinnedItems)
const pinnedRows = computed(() => projection.value.pinnedRows)
const spaceRows = computed(() => projection.value.spaceRows)
- const globalTasks = computed(() => projection.value.globalTasks)
+ const taskRows = computed(() => projection.value.taskRows)
useIntervalFn(() => {
relativeTimeNow.value = Date.now()
@@ -67,7 +71,10 @@ export function useTaskIndexController(options: UseTaskIndexControllerOptions) {
}
function toggleSpace(spaceId: string) {
- void options.sidebar().setSpaceExpanded(spaceId, !isSpaceExpanded(spaceId))
+ const expanded = !isSpaceExpanded(spaceId)
+ if (!expanded)
+ resetConversationGroups(groupKey => groupKey === spaceConversationGroupKey(spaceId))
+ void options.sidebar().setSpaceExpanded(spaceId, expanded)
}
function isSectionExpanded(section: DesktopTaskSidebarSection) {
@@ -75,9 +82,24 @@ export function useTaskIndexController(options: UseTaskIndexControllerOptions) {
}
function setSectionExpanded(section: DesktopTaskSidebarSection, expanded: boolean) {
+ if (!expanded) {
+ resetConversationGroups(groupKey => section === 'tasks'
+ ? groupKey === DESKTOP_TASK_SIDEBAR_TASKS_GROUP_KEY
+ : groupKey.startsWith('space:'))
+ }
void options.sidebar().setSectionExpanded(section, expanded)
}
+ function expandConversationGroup(groupKey: string) {
+ expandedConversationGroups.value = new Set([groupKey])
+ }
+
+ function resetConversationGroups(predicate: (groupKey: string) => boolean) {
+ const next = [...expandedConversationGroups.value].filter(groupKey => !predicate(groupKey))
+ if (next.length !== expandedConversationGroups.value.size)
+ expandedConversationGroups.value = new Set(next)
+ }
+
function recordScrollAnchor(section: DesktopTaskSidebarSection, index: number) {
options.sidebar().recordScrollAnchor(section, index)
}
@@ -142,8 +164,8 @@ export function useTaskIndexController(options: UseTaskIndexControllerOptions) {
dropPinnedItem,
endPinnedDrag,
enterPinnedDropTarget,
+ expandConversationGroup,
getPinnedDropPosition,
- globalTasks,
isSectionExpanded,
isSpaceExpanded,
pinTask,
@@ -154,6 +176,7 @@ export function useTaskIndexController(options: UseTaskIndexControllerOptions) {
scrollAnchors,
setSectionExpanded,
spaceRows,
+ taskRows,
relativeTimeNow,
toggleSpace,
unpinItem,
diff --git a/pr-assets/session-list-collapse-demo.png b/pr-assets/session-list-collapse-demo.png
new file mode 100644
index 00000000..7f0212ea
Binary files /dev/null and b/pr-assets/session-list-collapse-demo.png differ