From 7f72884bb3a604490acb8bb6804df28dc24c4d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CM4n5ter=E2=80=9D?= Date: Fri, 13 Nov 2026 18:34:23 +0800 Subject: [PATCH] feat(workbench): show six conversations per project Generated-by: OpenAI Codex --- .../agent/conversations/ui/SessionSurface.tsx | 3 + .../agent/state/src/session_catalog/query.rs | 5 +- .../tests/fixtures/components.tsx | 1 + .../maka/workbench/app/WorkspaceBrowser.tsx | 116 +++++++++++++++--- products/maka/workbench/app/browsing.ts | 10 +- .../maka/workbench/app/conversationGroups.ts | 39 ++++-- .../maka/workbench/app/locale-workspace.ts | 13 ++ .../tests/conversation-groups.test.mjs | 39 +++++- 8 files changed, 193 insertions(+), 33 deletions(-) diff --git a/plugins/agent/conversations/ui/SessionSurface.tsx b/plugins/agent/conversations/ui/SessionSurface.tsx index 905c16e5a51..fe8a4e3ce98 100644 --- a/plugins/agent/conversations/ui/SessionSurface.tsx +++ b/plugins/agent/conversations/ui/SessionSurface.tsx @@ -1938,6 +1938,7 @@ export function createConversationUi( // Kept here so the browser's collapsed workspaces outlive its unmounting // and stepping between conversations skips them as the browser does. const collapsedGroups = signal>(new Set()); + const expandedProjects = signal>(new Set()); const stepSession = (step: 1 | -1) => { const current = overlay(); if (switching() || busy() || (current && current.kind !== 'sessions')) return; @@ -1950,6 +1951,7 @@ export function createConversationUi( ), collapsedGroups(), selected(), + platform.idiom === 'terminal' ? undefined : expandedProjects(), ); const index = rows.indexOf(selected() ?? ''); const next = rows[index === -1 ? 0 : index + step]; @@ -2009,6 +2011,7 @@ export function createConversationUi( beside: () => shell()?.besideSessionIds?.() ?? [], openBeside: (sessionId) => shell()?.openBeside?.(sessionId), collapsedGroups, + expandedProjects, }; let wasDocked = shell()?.sidebarDocked(); let previousWidth = windowSize().width; diff --git a/plugins/agent/state/src/session_catalog/query.rs b/plugins/agent/state/src/session_catalog/query.rs index 8cc1beadb95..b35431356bc 100644 --- a/plugins/agent/state/src/session_catalog/query.rs +++ b/plugins/agent/state/src/session_catalog/query.rs @@ -47,7 +47,10 @@ const BASE: &str = "WITH base AS ( (SELECT e.schema_version FROM agent_catalog_events e WHERE e.owner=?1 AND e.session=s.id ORDER BY revision DESC LIMIT 1) AS control_schema, s.catalog_unpinned AS unpinned, source.run AS source_run,COALESCE(source.sequence,0)>COALESCE(k.read_through,0) AS source_unread, - -max(s.created_at,COALESCE((SELECT MAX(at) FROM agent_catalog_messages m WHERE m.owner=?1 AND m.session=s.id AND m.sequence>COALESCE(source.sequence,0)),0),COALESCE(CAST(json_extract(source_summary.summary_json,'$.summary.updatedAt') AS INTEGER),0)) AS recency + -max(s.created_at,COALESCE((SELECT m.at FROM agent_catalog_runs admitted + JOIN agent_catalog_messages m ON m.owner=admitted.owner AND m.session=admitted.session AND m.sequence=admitted.admission_sequence + WHERE admitted.owner=?1 AND admitted.session=s.id AND admitted.admission_sequence>COALESCE(source.sequence,0) + ORDER BY admitted.admission_sequence DESC LIMIT 1),0),COALESCE(CAST(json_extract(source_summary.summary_json,'$.summary.updatedAt') AS INTEGER),0)) AS recency FROM session_control s JOIN session_creations route ON route.session_id=s.id AND route.ready=1 AND route.visible=1 LEFT JOIN workspace_bindings b ON b.id=json_extract(s.configuration,'$.workspace.id') diff --git a/products/maka/terminal-shell/tests/fixtures/components.tsx b/products/maka/terminal-shell/tests/fixtures/components.tsx index 5a8e0c9960f..d16f723e982 100644 --- a/products/maka/terminal-shell/tests/fixtures/components.tsx +++ b/products/maka/terminal-shell/tests/fixtures/components.tsx @@ -400,6 +400,7 @@ function WorkspacePages(props: { busy: boolean }) { filter: () => 'all', needsYouCount: () => 0, sessions, + hasMore: () => page < 3, loading, projects: () => props.busy diff --git a/products/maka/workbench/app/WorkspaceBrowser.tsx b/products/maka/workbench/app/WorkspaceBrowser.tsx index ad5da2722a5..bb40e9335b4 100644 --- a/products/maka/workbench/app/WorkspaceBrowser.tsx +++ b/products/maka/workbench/app/WorkspaceBrowser.tsx @@ -51,7 +51,12 @@ import { import { Icon } from './Icon'; import type { Browsing, BrowserProject, BrowserScope, BrowserWorkspace } from './browsing'; import { SessionActions } from './SessionActions'; -import { groupWorkspace, groupConversations, type ConversationGroup } from './conversationGroups'; +import { + groupWorkspace, + groupConversations, + projectSessionLimit, + type ConversationGroup, +} from './conversationGroups'; import { hasForkPrefix, sessionActionTarget, @@ -97,6 +102,7 @@ export type WorkspaceBrowserProps = { * next and previous conversation skip them. */ collapsedGroups?: Signal>; + expandedProjects?: Signal>; }; type WorkspaceGroup = ConversationGroup & { name: string }; @@ -174,10 +180,8 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) { const scopesById = computed(() => new Map(scopes().map((scope) => [scope.id, scope]))); const firstUnpinned = computed(() => visibleSessions().find((session) => !session.pinned)?.id); const hasPinned = () => visibleSessions()[0]?.pinned === true; - // The desktop lists conversations under their workspaces instead of - // choosing a scope: pinned ones first, then each workspace in the order of - // its latest loaded conversation, then active projects with none loaded. const collapsed = props.collapsedGroups ?? signal>(new Set()); + const expanded = props.expandedProjects ?? signal>(new Set()); const groups = computed(() => groupConversations(visibleSessions(), browser.projects(), browser.filter() === 'all').map( (group): WorkspaceGroup => ({ @@ -189,24 +193,33 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) { ), ); const nestedRows = computed(() => new Set(groups().flatMap((group) => [...group.nested]))); - const groupsById = computed( - () => new Map(groups().map((group) => [`group:${group.key}`, group])), - ); + const groupsById = computed(() => new Map(groups().map((group) => [group.key, group]))); const rowIds = computed(() => platform.idiom === 'terminal' ? choosingWorkspace() ? scopes().map((scope) => scope.id) : visibleSessions().map((session) => session.id) - : groups().flatMap((group) => [ - `group:${group.key}`, - ...(collapsed().has(group.key) ? [] : group.sessions), - ]), + : groups().flatMap((group) => { + const heading = `group:${group.key}`; + if (collapsed().has(group.key)) return [heading]; + const limit = projectSessionLimit(group); + if (group.sessions.length <= limit) return [heading, ...group.sessions]; + const sessions = expanded().has(group.key) + ? group.sessions + : group.sessions.slice(0, limit); + return [heading, ...sessions, `show-more:${group.key}`]; + }), ); const toggleGroup = (key: string) => { const next = new Set(collapsed()); if (!next.delete(key)) next.add(key); collapsed.set(next); }; + const toggleProjectSessions = (key: string) => { + const next = new Set(expanded()); + if (!next.delete(key)) next.add(key); + expanded.set(next); + }; const select = async ({ id, origin }: { id: string; origin: string | null }) => { if (!alive || props.selected() !== origin || props.switching()) return; navigationNotice.set(null); @@ -278,12 +291,37 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) { // Hidden rows can extend a page without making the List emit its end edge again. const endRequest = signal<{ rows: number; + loaded: number; scope: BrowserScope; filter: ReturnType; } | null>(null); const loadMore = () => { if (!choosingWorkspace()) - endRequest.set({ rows: rowIds().length, scope: browser.scope(), filter: browser.filter() }); + endRequest.set({ + rows: rowIds().length, + loaded: browser.sessions().length, + scope: browser.scope(), + filter: browser.filter(), + }); + }; + const showOlder = () => { + const request = endRequest(); + return ( + platform.idiom !== 'terminal' && + !!request && + browser.hasMore() && + !browser.error() && + request.scope === browser.scope() && + request.filter === browser.filter() && + rowIds().length === request.rows && + browser.sessions().length > request.loaded && + groups().some( + (group) => + !collapsed().has(group.key) && + !expanded().has(group.key) && + group.sessions.length > projectSessionLimit(group), + ) + ); }; effect(() => { const rows = rowIds().length; @@ -301,8 +339,10 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) { endRequest.set({ ...requested, rows }); return; } - if (choosingWorkspace() || browser.loading() || browser.error()) return; - if (requested || rows < Math.ceil(viewportHeight() / rowHeight())) { + if (choosingWorkspace() || browser.loading() || browser.error() || showOlder()) return; + if (!requested && browser.hasMore() && rows < Math.ceil(viewportHeight() / rowHeight())) { + loadMore(); + } else if (requested) { void browser.loadMore().then((result) => { if (alive && endRequest() === requested && result === 'complete') endRequest.set(null); }); @@ -595,6 +635,18 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) { )} onLayout={({ bounds: frame }) => viewportHeight.set(frame.size.height)} onLoadMore={loadMore} + footer={() => ( + + + + )} style={{ flex: 1, minHeight: 32 }} accessibilityLabel={choosingWorkspace() ? tr('Workspace scopes') : tr('Conversations')} > @@ -660,9 +712,41 @@ export function WorkspaceBrowser(props: WorkspaceBrowserProps) { ); } - const initialGroup = groupsById().get(key()); + if (key().startsWith('show-more:')) { + const groupKey = key().slice('show-more:'.length); + const open = () => expanded().has(groupKey); + return ( + + ); + } + const groupKey = key().startsWith('group:') ? key().slice('group:'.length) : ''; + const initialGroup = groupsById().get(groupKey); if (initialGroup) { - const group = () => groupsById().get(key()) ?? initialGroup; + const group = () => groupsById().get(groupKey) ?? initialGroup; const open = () => !collapsed().has(group().key); const workspace = () => groupWorkspace(group()); return ( diff --git a/products/maka/workbench/app/browsing.ts b/products/maka/workbench/app/browsing.ts index 0e30dc0ff97..8dd03eaf6c2 100644 --- a/products/maka/workbench/app/browsing.ts +++ b/products/maka/workbench/app/browsing.ts @@ -44,6 +44,7 @@ export type Browsing = { sessions(): BrowserSession[]; needsYouCount(): number; projects(): BrowserProject[]; + hasMore(): boolean; loading(): boolean; error(): string; projectError(): string; @@ -82,6 +83,7 @@ export function createBrowsing(host: Host): Browsing { const sessions = signal([]); const needsYouCount = signal(0); const projects = signal([]); + const hasMore = signal(false); const loading = signal(true); const error = createText(); const projectError = createText(); @@ -150,6 +152,7 @@ export function createBrowsing(host: Host): Browsing { error.set(''); if (clear) { sessions.set([]); + hasMore.set(false); needsYouCount.set(0); } }); @@ -218,6 +221,7 @@ export function createBrowsing(host: Host): Browsing { read.extend = false; batch(() => { sessions.set(snapshot); + hasMore.set(tail.after !== null); needsYouCount.set(next.needsYouCount); loading.set(false); error.set(''); @@ -279,7 +283,10 @@ export function createBrowsing(host: Host): Browsing { page = next; read.extent = snapshot.at(-1)?.id ?? null; read.extend = false; - sessions.set(snapshot); + batch(() => { + sessions.set(snapshot); + hasMore.set(next.after !== null); + }); return 'advanced'; } catch (reason) { if (!owns(read) || continuation !== original) return 'waiting'; @@ -371,6 +378,7 @@ export function createBrowsing(host: Host): Browsing { sessions, needsYouCount, projects, + hasMore, loading, error, projectError, diff --git a/products/maka/workbench/app/conversationGroups.ts b/products/maka/workbench/app/conversationGroups.ts index 542d724a91d..4eff5dbae37 100644 --- a/products/maka/workbench/app/conversationGroups.ts +++ b/products/maka/workbench/app/conversationGroups.ts @@ -43,11 +43,9 @@ export type ConversationGroup = { nested: ReadonlySet; }; -/** - * The browser's groups: pinned conversations first, then each workspace in - * the order of its latest loaded conversation, then, when `withEmpty`, the - * active projects with none loaded. - */ +const PROJECT_SESSION_LIMIT = 6; + +/** Pinned conversations, registered projects in catalog order, then other workspaces. */ export function groupConversations( sessions: readonly Groupable[], projects: readonly Project[], @@ -85,24 +83,43 @@ export function groupConversations( for (const project of byProject.values()) if (!project.archived && !groups.has(`project:${project.reference.id}`)) add(`project:${project.reference.id}`, false, project, null); - return [...groups.values()].map(({ members, ...group }) => { + const ordered = []; + const pinned = groups.get('pinned'); + if (pinned) ordered.push(pinned); + for (const project of byProject.values()) { + const group = groups.get(`project:${project.reference.id}`); + if (group) ordered.push(group); + } + for (const group of groups.values()) { + if (!group.pinned && !group.project) ordered.push(group); + } + return ordered.map(({ members, ...group }) => { const { order, nested } = nestCreated(members); return { ...group, sessions: order, nested }; }); } +export function projectSessionLimit(group: ConversationGroup): number { + return group.key.startsWith('project:') ? PROJECT_SESSION_LIMIT : group.sessions.length; +} + /** - * The conversations in the order the browser lists them: a collapsed group's - * are left out, except `current`, so stepping from it still has neighbors. + * Step through the visible conversations, keeping a hidden current row as a starting point. */ export function browserOrder( groups: readonly ConversationGroup[], collapsed: ReadonlySet, current: string | null, + expandedProjects?: ReadonlySet, ): string[] { - return groups.flatMap((group) => - collapsed.has(group.key) ? group.sessions.filter((id) => id === current) : group.sessions, - ); + return groups.flatMap((group) => { + const limit = collapsed.has(group.key) + ? 0 + : expandedProjects && !expandedProjects.has(group.key) + ? projectSessionLimit(group) + : group.sessions.length; + return group.sessions.filter((id, index) => index < limit || id === current); + }); } /** Resolve creation through an owned workspace, never a display path. */ diff --git a/products/maka/workbench/app/locale-workspace.ts b/products/maka/workbench/app/locale-workspace.ts index a4bd88f7cd9..072a8437275 100644 --- a/products/maka/workbench/app/locale-workspace.ts +++ b/products/maka/workbench/app/locale-workspace.ts @@ -99,6 +99,19 @@ export const workspaceMessages = { 'Close side pane': ['Close side pane', '关闭右侧对话', '關閉右側對話'], 'Collapse {name}': ['Collapse {name}', '收起 {name}', '收合 {name}'], 'Expand {name}': ['Expand {name}', '展开 {name}', '展開 {name}'], + 'Show more project conversations': ['Show more', '展开显示', '展開顯示'], + 'Show fewer project conversations': ['Show less', '收起', '收合'], + 'Load older conversations': ['Load older conversations', '加载更早的会话', '載入更早的對話'], + 'Show more conversations in {name}': [ + 'Show more conversations in {name}', + '展开 {name} 中的更多会话', + '展開 {name} 中的更多對話', + ], + 'Show fewer conversations in {name}': [ + 'Show fewer conversations in {name}', + '收起 {name} 中的更多会话', + '收合 {name} 中的更多對話', + ], 'Unpin conversation': ['Unpin conversation', '取消置顶', '取消置頂'], Pinned: ['Pinned', '已置顶', '已置頂'], 'Actions for {title}': ['Actions for {title}', '{title} 的操作', '{title} 的操作'], diff --git a/products/maka/workbench/tests/conversation-groups.test.mjs b/products/maka/workbench/tests/conversation-groups.test.mjs index a098d96db95..d604d44ce78 100644 --- a/products/maka/workbench/tests/conversation-groups.test.mjs +++ b/products/maka/workbench/tests/conversation-groups.test.mjs @@ -29,7 +29,7 @@ const session = (id, path, options = {}) => ({ }); const project = (id, archived = false) => ({ reference: { id }, archived }); -test('groups list pinned conversations, then workspaces, then empty projects', () => { +test('groups list pinned conversations, then projects in catalog order, then other workspaces', () => { const groups = groupConversations( [ session('pin', '/a', { pinned: true, project: 'p' }), @@ -44,14 +44,45 @@ test('groups list pinned conversations, then workspaces, then empty projects', ( groups.map((group) => [group.key, group.sessions]), [ ['pinned', ['pin']], - ['path:/b', ['root', 'child']], ['project:p', ['inProject']], ['project:empty', []], + ['path:/b', ['root', 'child']], ], ); assert.equal(groups[0].project, null); - assert.equal(groups[1].path, '/b'); - assert.deepEqual([...groups[1].nested], ['child']); + assert.equal(groups[3].path, '/b'); + assert.deepEqual([...groups[3].nested], ['child']); +}); + +test('project order is independent of conversation recency and stepping follows the preview', () => { + const recent = Array.from({ length: 8 }, (_, index) => + session(`b${index}`, '/b', { project: 'b' }), + ); + const groups = groupConversations( + [...recent, session('a', '/a', { project: 'a' })], + [project('a'), project('b')], + false, + ); + assert.deepEqual(browserOrder(groups, new Set(), null, new Set()), [ + 'a', + 'b0', + 'b1', + 'b2', + 'b3', + 'b4', + 'b5', + ]); + assert.deepEqual(browserOrder(groups, new Set(), null, new Set(['project:b'])), [ + 'a', + 'b0', + 'b1', + 'b2', + 'b3', + 'b4', + 'b5', + 'b6', + 'b7', + ]); }); test('stepping skips collapsed groups but keeps the open conversation', () => {