diff --git a/apps/buddy/electron/main/config/LexoraConfigStore.ts b/apps/buddy/electron/main/config/LexoraConfigStore.ts index 8945d630..6abe12ef 100644 --- a/apps/buddy/electron/main/config/LexoraConfigStore.ts +++ b/apps/buddy/electron/main/config/LexoraConfigStore.ts @@ -47,7 +47,7 @@ const desktopConfigSchema = z.object({ permission_mode: z.enum(BUDDY_PERMISSION_MODES).default(DEFAULT_DESKTOP_CHAT_PREFERENCES.permissionMode), welcome: z.enum(['none', 'random', ...DESKTOP_CHAT_WELCOME_VARIANT_IDS]).default(DEFAULT_DESKTOP_CHAT_PREFERENCES.welcome), }).passthrough().prefault({}), - context_panel_mode: z.enum(['task', 'independent']).default('task'), + context_panel_mode: z.enum(['task', 'space', 'independent']).default('task'), context_panel_global: z.boolean().default(false), keybindings: keybindingsSchema.default({}), task_sidebar_pinned_items: z.array(taskSidebarPinnedItemSchema) diff --git a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts index 797efbed..68cc5762 100644 --- a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts +++ b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts @@ -147,16 +147,16 @@ describe('lexoraConfigStore', () => { expect((await store.read()).desktop.language).toBe('en-US') }) - it('defaults global panels off and persists mode and visibility independently across restarts', async () => { + it.each(['independent', 'space'] as const)('defaults global panels off and persists %s mode and visibility independently across restarts', async (mode) => { const { configPath, store } = await createConfigStore() await mkdir(dirname(configPath), { recursive: true }) await writeFile(configPath, '[desktop]\nlanguage = "en-US"\n[future]\nvalue = true\n') expect((await store.read()).desktop).toMatchObject({ contextPanelMode: 'task', contextPanelGlobal: false }) - await store.update({ desktop: { contextPanelMode: 'independent', contextPanelGlobal: true } }) + await store.update({ desktop: { contextPanelMode: mode, contextPanelGlobal: true } }) await store.update({ desktop: { theme: 'dark' } }) const restarted = new LexoraConfigStore({ configPath }) expect((await restarted.read()).desktop).toMatchObject({ - contextPanelMode: 'independent', + contextPanelMode: mode, contextPanelGlobal: true, language: 'en-US', theme: 'dark', diff --git a/apps/buddy/electron/shared/desktopApi.ts b/apps/buddy/electron/shared/desktopApi.ts index f9e117f4..c1904c83 100644 --- a/apps/buddy/electron/shared/desktopApi.ts +++ b/apps/buddy/electron/shared/desktopApi.ts @@ -137,7 +137,7 @@ export interface DesktopTaskSidebarPreferences { export type DesktopChatWelcomeVariantId = typeof DESKTOP_CHAT_WELCOME_VARIANT_IDS[number] export type DesktopChatWelcomePreference = 'none' | 'random' | DesktopChatWelcomeVariantId -export type DesktopContextPanelMode = 'task' | 'independent' +export type DesktopContextPanelMode = 'task' | 'space' | 'independent' export const DESKTOP_CHAT_OUTLINE_POSITIONS = [ 'top-left', diff --git a/apps/buddy/electron/shared/desktopApiSchemas.ts b/apps/buddy/electron/shared/desktopApiSchemas.ts index abc356e8..39c4e77d 100644 --- a/apps/buddy/electron/shared/desktopApiSchemas.ts +++ b/apps/buddy/electron/shared/desktopApiSchemas.ts @@ -69,7 +69,7 @@ export const lexoraConfigPatchSchema: z.ZodType = z.object({ permissionMode: z.enum(BUDDY_PERMISSION_MODES).optional(), welcome: z.enum(['none', 'random', ...DESKTOP_CHAT_WELCOME_VARIANT_IDS]).optional(), }).strict().optional(), - contextPanelMode: z.enum(['task', 'independent']).optional(), + contextPanelMode: z.enum(['task', 'space', 'independent']).optional(), contextPanelGlobal: z.boolean().optional(), keybindings: keybindingsSchema.optional(), taskSidebarPinnedItems: taskSidebarPinnedItemsSchema.optional(), diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index 4fa35375..a9da41a0 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -32,6 +32,7 @@ import { desktopWorkbenchKey } from '../workbench/desktopWorkbenchContext' import { useDesktopKeybindings } from '../workbench/useDesktopKeybindings' import { useDesktopWorkbench } from '../workbench/useDesktopWorkbench' import { useExtensionContributions } from '../workbench/useExtensionContributions' +import { useTaskResourceSpaces } from '../workbench/useTaskResourceSpaces' import { createDesktopCapabilities } from './desktopCapabilities' import { useDesktopAppState } from './useDesktopAppState' import { useDesktopLifecycle } from './useDesktopLifecycle' @@ -105,6 +106,7 @@ useProvideExtensionContext({ settingsLocation: settingsRegistry.extensionLocatio onScopeDispose(workbench.controller.subscribe(() => void nextTick(extensionViews.layout))) const selectedTask = workbench.activeTask const resources = useTaskResourcePanel({ + scopeSpaceIds: useTaskResourceSpaces({ controller: workbench.controller, pool: workbench.pool, tasks: taskIndex.index.tasks }), activeConversationId: workbench.activeTaskId, activeDraftId: computed(() => selectedTask.value?.workspace.composer.draftId.value ?? null), activeBranchId: computed(() => selectedTask.value?.workspace.session.activeBranchId.value ?? null), diff --git a/apps/buddy/src/app/workbench/useTaskResourceSpaces.ts b/apps/buddy/src/app/workbench/useTaskResourceSpaces.ts new file mode 100644 index 00000000..def18281 --- /dev/null +++ b/apps/buddy/src/app/workbench/useTaskResourceSpaces.ts @@ -0,0 +1,40 @@ +import type { LocalConversationSummary } from '@buddy-shared/conversation/conversationApi' +import type { Ref } from 'vue' +import type { TaskWorkspacePool } from './TaskWorkspacePool' +import type { WorkbenchController } from '@/workbench/services/WorkbenchController' +import { computed, onScopeDispose, shallowRef } from 'vue' + +export function useTaskResourceSpaces(options: { + controller: WorkbenchController + pool: TaskWorkspacePool + tasks: Readonly>>> +}) { + const { controller, pool } = options + const openTasks = shallowRef(collect()) + function collect() { + return Object.values(controller.layout.views) + .filter(view => view.resource.scheme === 'task' || view.resource.scheme === 'draft') + .map(({ resource }) => ({ resource, task: pool.peek(resource) })) + } + function reconcile() { + openTasks.value = collect() + } + onScopeDispose(pool.onDidChange(reconcile).dispose) + onScopeDispose(controller.onDidChangeLayout((change) => { + if (change.changedViewIds.length || change.removedViewIds.length || change.kind === 'restored') + reconcile() + }).dispose) + return computed>(() => { + const spaces = new Map(options.tasks.value.map(task => [`task:${task.id}`, task.spaceId])) + for (const { resource, task } of openTasks.value) { + const key = `${resource.scheme}:${resource.id}` + if (spaces.has(key)) + continue + if (task) + spaces.set(key, task.session.spaceId.value) + else if (resource.scheme === 'draft') + spaces.set(key, typeof resource.data.spaceId === 'string' ? resource.data.spaceId : null) + } + return spaces + }) +} diff --git a/apps/buddy/src/i18n/locales/en-US/settings.ts b/apps/buddy/src/i18n/locales/en-US/settings.ts index e735b5d6..208fe69d 100644 --- a/apps/buddy/src/i18n/locales/en-US/settings.ts +++ b/apps/buddy/src/i18n/locales/en-US/settings.ts @@ -108,11 +108,12 @@ export default { 'desktop.browser.screenshotFailed': 'Could not capture the screenshot. Retry or change the destination in browser settings.', 'desktop.settings.contextPanel': 'Resource panel', 'desktop.settings.contextPanelMode': 'Browsing mode', - 'desktop.settings.contextPanelModeDescription': 'Task-linked selects resources when focusing a task. Independent keeps the current selection.', + 'desktop.settings.contextPanelModeDescription': 'Follow task or space resources, or browse independently.', 'desktop.settings.contextPanelTask': 'Task-linked', + 'desktop.settings.contextPanelSpace': 'Space-linked', 'desktop.settings.contextPanelIndependent': 'Independent', 'desktop.settings.contextPanelGlobal': 'Follow resources across pages', - 'desktop.settings.contextPanelGlobalDescription': 'Follow task resources in settings and automations too. Open tabs always remain available.', + 'desktop.settings.contextPanelGlobalDescription': 'Show the resource panel in settings and automations using the current browsing mode. Open tabs always remain available.', 'desktop.settings.proxy.title': 'Network proxy', 'desktop.settings.proxy.mode': 'Connection mode', 'desktop.settings.proxy.description': 'Changes apply immediately. Active network requests may need to be retried.', diff --git a/apps/buddy/src/i18n/locales/zh-CN/settings.ts b/apps/buddy/src/i18n/locales/zh-CN/settings.ts index 5e5cd133..58072ed0 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/settings.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/settings.ts @@ -106,11 +106,12 @@ export default { 'desktop.browser.screenshotFailed': '截图失败,请重试或在浏览器设置中切换保存方式。', 'desktop.settings.contextPanel': '资源面板', 'desktop.settings.contextPanelMode': '浏览模式', - 'desktop.settings.contextPanelModeDescription': '任务联动在聚焦任务时选择其资源,独立浏览保留当前选择。', + 'desktop.settings.contextPanelModeDescription': '按任务或空间切换资源,独立浏览保留当前选择。', 'desktop.settings.contextPanelTask': '任务联动', + 'desktop.settings.contextPanelSpace': '空间联动', 'desktop.settings.contextPanelIndependent': '独立浏览', 'desktop.settings.contextPanelGlobal': '跨页面联动', - 'desktop.settings.contextPanelGlobalDescription': '在设置和自动化中也跟随任务资源;已打开的标签始终保留。', + 'desktop.settings.contextPanelGlobalDescription': '在设置和自动化中也显示资源面板,沿用当前浏览模式;已打开的标签始终保留。', 'desktop.settings.proxy.title': '网络代理', 'desktop.settings.proxy.mode': '连接方式', 'desktop.settings.proxy.description': '修改后立即生效,进行中的网络请求可能需要重试。', diff --git a/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettingField.vue b/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettingField.vue index 0bb38e95..a036d3bb 100644 --- a/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettingField.vue +++ b/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettingField.vue @@ -14,6 +14,7 @@ const pending = shallowRef(false) const labelId = useId() const contextPanelModes = computed(() => [ { label: t('desktop.settings.contextPanelTask'), value: 'task' }, + { label: t('desktop.settings.contextPanelSpace'), value: 'space' }, { label: t('desktop.settings.contextPanelIndependent'), value: 'independent' }, ]) const labels = computed(() => ({ diff --git a/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts b/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts index 03986e97..0efa80d3 100644 --- a/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts +++ b/apps/buddy/src/modules/tasks/model/context-panel/readContextTab.ts @@ -1,4 +1,4 @@ -import type { ContextPanelScope, TaskContextTab } from './taskContextPanel' +import type { ContextPanelSelectionScope, TaskContextTab } from './taskContextPanel' import { artifactSchema } from '@buddy-shared/artifacts/artifactApi' import { changeSetSummarySchema } from '@buddy-shared/changes/changeApi' import { contextPanelSourceSchema } from '@buddy-shared/context-panel/contextPanel' @@ -7,7 +7,7 @@ import { z } from 'zod' const identity = z.string().min(1).max(256) const scopeSchema = z.union([z.literal('independent'), z.literal('workspace'), z.templateLiteral(['task:', z.string().min(1)]), z.templateLiteral(['draft:', z.string().min(1)])]) -const selectionSchema = z.tuple([scopeSchema, identity]) +const selectionSchema = z.tuple([z.union([scopeSchema, z.templateLiteral(['space:', z.string().min(1)])]), identity]) const base = z.object({ id: identity, scope: scopeSchema, source: contextPanelSourceSchema.optional() }) const tabSchema = z.discriminatedUnion('kind', [ base.extend({ kind: z.literal('browser'), conversationId: identity.nullable(), browserKey: identity.optional() }), @@ -24,7 +24,7 @@ export function readContextTab(value: unknown): TaskContextTab | null { return parsed.data } -export function readContextSelection(value: unknown): readonly [ContextPanelScope, string] | null { +export function readContextSelection(value: unknown): readonly [ContextPanelSelectionScope, string] | null { const parsed = selectionSchema.safeParse(value) return parsed.success ? parsed.data : null } diff --git a/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts b/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts index bfa09838..f2560a87 100644 --- a/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts +++ b/apps/buddy/src/modules/tasks/model/context-panel/taskContextPanel.ts @@ -1,3 +1,4 @@ +import type { DesktopContextPanelMode } from '@buddy-electron/shared/desktopApi' import type { LocalArtifact } from '@buddy-shared/artifacts/artifactApi' import type { LocalChangeSetSummary } from '@buddy-shared/changes/changeApi' import type { ContextPanelSource } from '@buddy-shared/context-panel/contextPanel' @@ -6,6 +7,14 @@ import type { SpaceFileTarget } from '@buddy-shared/spaces/spaceFileApi' export type ArtifactViewMode = 'preview' | 'source' export type ContextPanelScope = `task:${string}` | `draft:${string}` | 'independent' | 'workspace' +export type ContextPanelSelectionScope = ContextPanelScope | `space:${string}` + +export function contextPanelSelectionScope(mode: DesktopContextPanelMode, scope: ContextPanelScope, spaceIds: ReadonlyMap): ContextPanelSelectionScope { + if (mode === 'independent') + return 'independent' + const spaceId = mode === 'space' ? spaceIds.get(scope) : null + return spaceId ? `space:${spaceId}` : scope +} interface ContextTabSource { scope: ContextPanelScope diff --git a/apps/buddy/src/modules/tasks/state/context-panel/__tests__/contextPanelFixture.ts b/apps/buddy/src/modules/tasks/state/context-panel/__tests__/contextPanelFixture.ts index e0ab14e5..807a8289 100644 --- a/apps/buddy/src/modules/tasks/state/context-panel/__tests__/contextPanelFixture.ts +++ b/apps/buddy/src/modules/tasks/state/context-panel/__tests__/contextPanelFixture.ts @@ -19,6 +19,7 @@ export function contextPanelFixture(overrides: Partial(mode), + activeConversationId: shallowRef('a'), + activeDraftId: shallowRef(null), + scopeSpaceIds: shallowRef>(new Map([ + ['task:a', 'first'], + ['task:b', 'first'], + ['task:c', 'second'], + ['task:unbound-a', null], + ['task:unbound-b', null], + ['draft:new', 'first'], + ])), + } + const hostFixture = contextPanelFixture(options) + const panel = hostFixture.scope.run(() => useTaskContextPanel(hostFixture.options))! + return { ...options, panel, host: hostFixture.host } +} + +describe('space-linked resources', () => { + it('shares tabs and selection by space identity without requiring a working directory', () => { + const f = fixture() + f.panel.addBrowser() + const first = f.panel.activeTab.value! + f.panel.openChanges() + const changes = f.panel.activeTab.value! + f.panel.selectTab(first.id) + f.activeConversationId.value = 'b' + expect(f.panel.tabs.value).toEqual([first, changes]) + expect(f.panel.activeTab.value).toEqual(first) + expect(f.panel.canAddChanges.value).toBe(true) + f.panel.openChanges() + const secondTaskChanges = f.panel.activeTab.value! + f.activeConversationId.value = 'a' + expect(f.panel.activeTab.value).toEqual(secondTaskChanges) + expect(f.panel.canAddChanges.value).toBe(false) + expect(f.panel.fileEntry.value).toBeNull() + f.activeConversationId.value = 'c' + expect(f.panel.tabs.value).toEqual([]) + f.panel.addBrowser() + const otherSpace = f.panel.activeTab.value! + f.activeConversationId.value = 'b' + expect(f.panel.activeTab.value).toEqual(secondTaskChanges) + f.activeConversationId.value = 'c' + expect(f.panel.activeTab.value).toEqual(otherSpace) + }) + + it('keeps tasks and drafts without a space isolated', () => { + const f = fixture() + const ids = ['unbound-a', 'unbound-b'] + const tabs = ids.map((id) => { + f.activeConversationId.value = id + expect(f.panel.tabs.value).toEqual([]) + f.panel.addBrowser() + return f.panel.activeTab.value! + }) + f.activeConversationId.value = null + f.activeDraftId.value = 'unbound-draft' + expect(f.panel.tabs.value).toEqual([]) + f.panel.addBrowser() + const draft = f.panel.activeTab.value! + ids.forEach((id, index) => { + f.activeConversationId.value = id + expect(f.panel.tabs.value).toEqual([tabs[index]]) + }) + f.activeConversationId.value = null + expect(f.panel.tabs.value).toEqual([draft]) + }) + + it('retains separate selections when changing modes and never reassigns resource ownership', () => { + const f = fixture('task') + f.panel.addBrowser() + const first = f.panel.activeTab.value! + f.panel.addBrowser() + const alternate = f.panel.activeTab.value! + f.panel.selectTab(first.id) + f.activeConversationId.value = 'b' + f.panel.addBrowser() + const second = f.panel.activeTab.value! + f.activeConversationId.value = 'a' + f.mode.value = 'space' + expect(f.panel.tabs.value).toEqual([first, alternate, second]) + expect(f.panel.activeTab.value).toEqual(first) + f.panel.selectTab(second.id) + f.mode.value = 'independent' + expect(f.panel.activeTab.value).toEqual(second) + f.panel.addBrowser() + const independent = f.panel.activeTab.value! + f.mode.value = 'task' + expect(f.panel.tabs.value).toEqual([first, alternate]) + expect(f.panel.activeTab.value).toEqual(first) + f.mode.value = 'space' + expect(f.panel.activeTab.value).toEqual(second) + expect(f.panel.tabs.value).not.toContainEqual(independent) + expect(f.panel.allTabs.value).toEqual([first, alternate, second, independent]) + }) + + it('restores a space selection even when task membership is loaded after the snapshot', () => { + const f = fixture() + f.panel.addBrowser() + const selected = f.panel.activeTab.value! + f.activeConversationId.value = 'b' + f.panel.addBrowser() + f.panel.selectTab(selected.id) + const spaceIds = shallowRef>(new Map()) + const restored = createTaskPanel({ mode: f.mode, activeConversationId: f.activeConversationId, scopeSpaceIds: spaceIds }) + restored.restoreSnapshot(JSON.parse(JSON.stringify(f.panel.snapshot()))) + spaceIds.value = f.scopeSpaceIds.value + expect(restored.tabs.value).toEqual(f.panel.tabs.value) + expect(restored.activeTab.value).toEqual(selected) + }) + + it('keeps the existing last-tab fallback when restoring task tabs without saved selections', () => { + const f = fixture('task') + f.panel.addBrowser() + f.panel.addBrowser() + const restored = createTaskPanel({ activeConversationId: f.activeConversationId }) + restored.restoreSnapshot({ tabs: f.panel.snapshot().tabs }) + expect(restored.activeTab.value).toEqual(f.panel.activeTab.value) + }) + + it('moves task resources with their space membership and deletes only the owning task resources', () => { + const f = fixture() + f.panel.addBrowser() + const first = f.panel.activeTab.value! + f.activeConversationId.value = 'b' + f.panel.addBrowser() + const second = f.panel.activeTab.value! + f.scopeSpaceIds.value = new Map([...f.scopeSpaceIds.value, ['task:b', 'second']]) + expect(f.panel.tabs.value).toEqual([second]) + f.activeConversationId.value = 'a' + expect(f.panel.tabs.value).toEqual([first]) + f.scopeSpaceIds.value = new Map([...f.scopeSpaceIds.value, ['task:b', 'first']]) + f.panel.selectTab(second.id) + f.panel.discardConversation('b') + f.panel.restoreTab(second) + expect(f.panel.tabs.value).toEqual([first]) + expect(f.panel.activeTab.value).toEqual(first) + }) + + it('shares draft resources and preserves selection through draft submission', () => { + const f = fixture() + f.activeConversationId.value = null + f.activeDraftId.value = 'new' + f.panel.addBrowser() + const draft = f.panel.activeTab.value! + f.activeConversationId.value = 'a' + expect(f.panel.activeTab.value).toEqual(draft) + f.scopeSpaceIds.value = new Map([...f.scopeSpaceIds.value, ['task:submitted', 'first']]) + f.panel.adoptDraft('new', 'submitted') + expect(f.panel.activeTab.value).toEqual({ ...draft, scope: 'task:submitted' }) + f.activeConversationId.value = 'submitted' + expect(f.panel.activeTab.value?.id).toBe(draft.id) + f.panel.discardConversation('submitted') + expect(f.panel.tabs.value).toEqual([]) + }) + + it('remembers the selected draft resource after moving to a space without a selection', async () => { + const f = fixture() + f.activeConversationId.value = null + f.activeDraftId.value = 'new' + f.panel.addBrowser() + const selected = f.panel.activeTab.value! + + f.scopeSpaceIds.value = new Map([...f.scopeSpaceIds.value, ['draft:new', 'second']]) + expect(f.panel.activeTab.value).toBe(selected) + expect(f.panel.snapshot().selections).toContainEqual(['space:second', selected.id]) + + await f.host.execute({ action: 'open', target: { kind: 'browser', source: { conversationId: 'c', runId: 'run-c' } } }, 'harness') + await nextTick() + expect(f.panel.tabs.value.map(tab => tab.id)).toEqual([selected.id, 'browser:c']) + expect(f.panel.activeTab.value).toBe(selected) + }) + + it('remembers the remaining resource when the selected resource moves out of the current space', async () => { + const f = fixture() + f.panel.addBrowser() + const remaining = f.panel.activeTab.value! + f.activeConversationId.value = null + f.activeDraftId.value = 'new' + f.panel.addBrowser() + const draft = f.panel.activeTab.value! + f.activeConversationId.value = 'a' + expect(f.panel.activeTab.value).toBe(draft) + + f.scopeSpaceIds.value = new Map([...f.scopeSpaceIds.value, ['draft:new', 'second']]) + expect(f.panel.activeTab.value).toBe(remaining) + expect(f.panel.snapshot().selections).toContainEqual(['space:first', remaining.id]) + + await f.host.execute({ action: 'open', target: { kind: 'browser', source: { conversationId: 'b', runId: 'run-b' } } }, 'harness') + await nextTick() + expect(f.panel.tabs.value.map(tab => tab.id)).toEqual([remaining.id, 'browser:b']) + expect(f.panel.activeTab.value).toBe(remaining) + }) + + it('keeps the selection when a background task in the same space presents resources', async () => { + const f = fixture() + const { panel } = f + panel.addBrowser() + const selected = panel.activeTab.value! + const source = { conversationId: 'b', runId: 'run-b' } + await f.host.execute({ action: 'open', target: { kind: 'browser', source } }, 'harness') + await nextTick() + expect(panel.tabs.value.map(tab => tab.id)).toEqual([selected.id, 'browser:b']) + expect(panel.activeTab.value).toEqual(selected) + f.activeConversationId.value = 'b' + expect(panel.activeTab.value).toEqual(selected) + }) +}) diff --git a/apps/buddy/src/modules/tasks/state/context-panel/typing.ts b/apps/buddy/src/modules/tasks/state/context-panel/typing.ts index 0c71dedf..d099d65e 100644 --- a/apps/buddy/src/modules/tasks/state/context-panel/typing.ts +++ b/apps/buddy/src/modules/tasks/state/context-panel/typing.ts @@ -7,6 +7,7 @@ import type { Ref } from 'vue' export interface UseTaskContextPanelOptions { mode: Readonly> + scopeSpaceIds: Readonly>> control: ContextPanelApi onError: () => void activeBranchId: Readonly> diff --git a/apps/buddy/src/modules/tasks/state/context-panel/useContextPanelTabs.ts b/apps/buddy/src/modules/tasks/state/context-panel/useContextPanelTabs.ts index 324b95df..02ba21f0 100644 --- a/apps/buddy/src/modules/tasks/state/context-panel/useContextPanelTabs.ts +++ b/apps/buddy/src/modules/tasks/state/context-panel/useContextPanelTabs.ts @@ -1,31 +1,42 @@ import type { DesktopContextPanelMode } from '@buddy-electron/shared/desktopApi' import type { Ref } from 'vue' -import type { ContextPanelScope, TaskContextTab } from '../../model/context-panel/taskContextPanel' +import type { ContextPanelScope, ContextPanelSelectionScope, TaskContextTab } from '../../model/context-panel/taskContextPanel' import { computed, readonly, shallowReactive, shallowRef, watch } from 'vue' -import { taskContextPanelScope } from '../../model/context-panel/taskContextPanel' +import { contextPanelSelectionScope, taskContextPanelScope } from '../../model/context-panel/taskContextPanel' export function useContextPanelTabs(options: { mode: Readonly> + scopeSpaceIds: Readonly>> conversationId: Readonly> draftId: Readonly> }) { const resources = shallowRef([]) - const selections = shallowReactive(new Map()) + const selections = shallowReactive(new Map()) const discardedScopes = new Set() const taskScope = computed(() => options.conversationId.value ? taskContextPanelScope(options.conversationId.value) : options.draftId.value ? `draft:${options.draftId.value}` : 'workspace') const scope = computed(() => options.mode.value === 'independent' ? 'independent' : taskScope.value) - const tabs = computed(() => scopeTabs(scope.value)) - const activeTab = computed(() => selectedTab(scope.value)) + const selectionScope = computed(() => selectionFor(taskScope.value)) + const tabs = computed(() => scopeTabs(selectionScope.value)) + const activeTab = computed(() => selectedTab(selectionScope.value)) - watch(options.mode, (mode) => { - if (mode === 'independent') { - const previous = selectedTab(taskScope.value) - if (previous) - selections.set('independent', previous.id) + watch(options.mode, (mode, previousMode) => { + const previous = selectedTab(selectionFor(taskScope.value, previousMode)) + if (previous && tabs.value.some(tab => tab.id === previous.id) + && (mode === 'independent' || !selections.has(selectionScope.value))) { + selections.set(selectionScope.value, previous.id) } }, { flush: 'sync' }) + watch([selectionScope, tabs], ([scope]) => { + const tab = activeTab.value + if (tab && scope.startsWith('space:') && selections.get(scope) !== tab.id) + selections.set(scope, tab.id) + }, { flush: 'sync' }) + + function selectionFor(scope: ContextPanelScope, mode = options.mode.value) { + return contextPanelSelectionScope(mode, scope, options.scopeSpaceIds.value) + } function adoptDraft(draftId: string, conversationId: string) { const draftScope: ContextPanelScope = `draft:${draftId}` const destination = taskContextPanelScope(conversationId) @@ -38,18 +49,20 @@ export function useContextPanelTabs(options: { selections.delete(draftScope) } - function scopeTabs(scope: ContextPanelScope) { - return scope === 'independent' ? resources.value : resources.value.filter(tab => tab.scope === scope) + function scopeTabs(scope: ContextPanelSelectionScope) { + if (scope === 'independent') + return resources.value + return resources.value.filter(tab => scope.startsWith('space:') ? selectionFor(tab.scope, 'space') === scope : tab.scope === scope) } - function selectedTab(scope: ContextPanelScope) { + function selectedTab(scope: ContextPanelSelectionScope) { const tabs = scopeTabs(scope) return tabs.find(tab => tab.id === selections.get(scope)) ?? tabs.at(-1) ?? null } function select(id: string) { if (tabs.value.some(tab => tab.id === id)) - selections.set(scope.value, id) + selections.set(selectionScope.value, id) } function put(tab: TaskContextTab, activate = true) { @@ -59,7 +72,7 @@ export function useContextPanelTabs(options: { ? resources.value.map(item => item.id === tab.id ? tab : item) : [...resources.value, tab] if (activate) - selections.set(options.mode.value === 'independent' ? 'independent' : tab.scope, tab.id) + selections.set(selectionFor(tab.scope), tab.id) } function close(id: string) { @@ -110,9 +123,9 @@ export function useContextPanelTabs(options: { function snapshot() { return { tabs: resources.value, selections: [...selections] } } - function restoreSelections(values: readonly (readonly [ContextPanelScope, string])[]) { + function restoreSelections(values: readonly (readonly [ContextPanelSelectionScope, string])[]) { for (const [scope, id] of values) { - if (scopeTabs(scope).some(tab => tab.id === id)) + if (resources.value.some(tab => tab.id === id)) selections.set(scope, id) } } diff --git a/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts b/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts index 5626bd86..d5d0014a 100644 --- a/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts +++ b/apps/buddy/src/modules/tasks/state/context-panel/useTaskContextPanel.ts @@ -11,11 +11,12 @@ import { resolveChatToolFileTarget } from '../../model/transcript/chatToolFileTa import { useContextPanelTabs } from './useContextPanelTabs' export function useTaskContextPanel(options: UseTaskContextPanelOptions) { - const store = useContextPanelTabs({ mode: options.mode, conversationId: options.activeConversationId, draftId: options.activeDraftId }) + const store = useContextPanelTabs({ mode: options.mode, scopeSpaceIds: options.scopeSpaceIds, conversationId: options.activeConversationId, draftId: options.activeDraftId }) + const linked = computed(() => options.mode.value !== 'independent') const control = useContextPanelControl({ api: options.control, getSource: () => contextTabSource(store.activeTab.value) - ?? (options.mode.value === 'task' && options.taskVisible.value ? currentSource() ?? null : null), + ?? (linked.value && options.taskVisible.value ? currentSource() ?? null : null), onError: options.onError, onTarget: (target) => { const tab = spaceTaskBrowserTab(target.source.conversationId) @@ -27,13 +28,13 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) { && space.primaryDirectory && space.primaryDirectory.revokedAt === null)) const currentFileSpace = computed(() => fileSpaces.value.find(space => space.id === options.activeSpace?.value?.id) ?? null) const fileEntry = computed(() => { - if (options.mode.value === 'independent') + if (!linked.value) return { kind: 'space-picker' as const } return options.taskVisible.value && currentFileSpace.value ? { kind: 'directory' as const, spaceId: currentFileSpace.value.id } : null }) - const canAddChanges = computed(() => options.mode.value === 'task' && options.taskVisible.value + const canAddChanges = computed(() => linked.value && options.taskVisible.value && Boolean(options.activeConversationId.value) && !store.tabs.value.some(tab => tab.kind === 'changes' && tab.conversationId === options.activeConversationId.value)) const changeRevision = computed(() => options.changeSets.value.filter(set => set.conversationId === options.activeConversationId.value) @@ -46,7 +47,7 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) { if (tab.kind !== 'changes' || tab.conversationId !== options.activeConversationId.value) return tab if (tab.branchId !== branchId) - return options.mode.value === 'task' ? { ...tab, branchId, revision, changeSet: null, source: currentSource() } : tab + return linked.value ? { ...tab, branchId, revision, changeSet: null, source: currentSource() } : tab return tab.revision !== revision ? { ...tab, revision } : tab }) }) @@ -120,7 +121,7 @@ export function useTaskContextPanel(options: UseTaskContextPanelOptions) { kind: 'files', rootName: directory.root.split(/[\\/]/).filter(Boolean).at(-1) ?? directory.root, target: { spaceId: space.id, directoryId: directory.id, revision: directory.revision, path: '' }, - source: options.mode.value === 'task' ? currentSource() : undefined, + source: linked.value ? currentSource() : undefined, }) }