feat: add persistent workbar browser tabs and scoped MCP routing

This commit is contained in:
mesalogo
2026-09-10 01:26:56 +08:00
parent 428b4c0d08
commit 80b0b18712
54 changed files with 5446 additions and 1468 deletions
+52
View File
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
agentRequestSchema,
browserCloseTabRequestSchema,
browserCreateTabRequestSchema,
browserLiveStateSchema,
browserNavigateRequestSchema,
browserSetViewportRequestSchema,
browserStopLoadingRequestSchema,
builtinEmbeddingConnectionId,
clipboardTextSchema,
@@ -23,9 +26,14 @@ describe('clipboard text contract', () => {
})
describe('browser control contracts', () => {
const tabId = '4d7886c3-4c9c-4e84-bf65-1a4a841f5296'
const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251'
const leaseToken = '7d201980-0ad4-4670-81d4-dc2bf79f03b2'
it('requires authoritative toolbar metadata and accepts committed URLs up to 8192 characters', () => {
const base = {
conversationId: 'conversation',
tabId,
status: 'ready' as const,
sessionActive: true,
isLoading: false,
@@ -51,10 +59,12 @@ describe('browser control contracts', () => {
expect(
browserNavigateRequestSchema.parse({
conversationId: 'conversation',
tabId,
url: 'https://example.com/'
})
).toEqual({
conversationId: 'conversation',
tabId,
url: 'https://example.com/'
})
expect(
@@ -70,6 +80,48 @@ describe('browser control contracts', () => {
}).success
).toBe(true)
})
it('uses opaque tab IDs and validates complete viewport ownership leases', () => {
expect(
browserCreateTabRequestSchema.parse({
conversationId: 'conversation',
workbarInstanceId
})
).toEqual({ conversationId: 'conversation', workbarInstanceId })
expect(
browserCloseTabRequestSchema.safeParse({
conversationId: 'conversation',
tabId
}).success
).toBe(true)
expect(
browserCloseTabRequestSchema.safeParse({
conversationId: 'conversation',
tabId: 'predictable-tab-name'
}).success
).toBe(false)
expect(
browserSetViewportRequestSchema.safeParse({
conversationId: 'conversation',
tabId,
leaseToken,
bounds: { x: 1, y: 2, width: 300, height: 400 }
}).success
).toBe(true)
expect(
browserSetViewportRequestSchema.safeParse({ tabId, leaseToken }).success
).toBe(false)
expect(
browserLiveStateSchema.safeParse({
conversationId: 'conversation',
status: 'ready',
sessionActive: true,
isLoading: false,
canGoBack: false,
updatedAt: 1
}).success
).toBe(false)
})
})
const baseInput = {
+185 -37
View File
@@ -30,8 +30,6 @@ import {
actorConversationSubagentActivitySchema,
legacyConversationSubagentActivitySchema,
legacyWorkModeSchema,
maximumConversationHistoryCharacters,
maximumConversationHistoryMessages,
type AssistantProject,
type AssistantArtifact,
type AssistantMemory,
@@ -346,11 +344,9 @@ export const agentRequestSchema = z
contextIds: z.array(z.string().uuid()).max(8).optional(),
history: z
.array(conversationHistoryMessageSchema)
.max(maximumConversationHistoryMessages)
.optional(),
historyMessageIds: z
.array(z.string().uuid())
.max(maximumConversationHistoryMessages)
.optional(),
currentUserMessageId: z.string().uuid().optional(),
currentAssistantMessageId: z.string().uuid().optional(),
@@ -359,18 +355,6 @@ export const agentRequestSchema = z
})
.strict()
.superRefine((request, context) => {
const historyLength =
request.history?.reduce(
(total, message) => total + message.content.length,
0
) ?? 0
if (historyLength > maximumConversationHistoryCharacters) {
context.addIssue({
code: 'custom',
path: ['history'],
message: `会话历史总长度不能超过 ${maximumConversationHistoryCharacters.toLocaleString()} 个字符`
})
}
if (
request.historyMessageIds &&
request.historyMessageIds.length !==
@@ -683,6 +667,7 @@ const embeddingConnectionInputSchema = z.discriminatedUnion('kind', [
])
export const runtimeModelSourceSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('default') }).strict(),
z.object({ kind: z.literal('platform') }).strict(),
z
.object({
@@ -1373,18 +1358,28 @@ export type AppInfo = {
shortcutStatus?: GlobalShortcutRegistrationStatus
}
export const browserTabIdSchema = z.string().uuid().brand<'BrowserTabId'>()
export type BrowserTabId = z.infer<typeof browserTabIdSchema>
export const browserWorkbarInstanceIdSchema = z.string().uuid()
export const browserViewportLeaseTokenSchema = z.string().uuid()
const browserStatusSchema = z.enum([
'creating',
'loading',
'ready',
'acting',
'interactive',
'failed',
'stopped'
])
export const browserLiveStateSchema = z
.object({
conversationId: conversationIdSchema,
status: z.enum([
'creating',
'loading',
'ready',
'acting',
'interactive',
'failed',
'stopped'
]),
tabId: browserTabIdSchema,
ownerWindowId: z.number().int().nonnegative().optional(),
status: browserStatusSchema,
sessionActive: z.boolean(),
isLoading: z.boolean(),
canGoBack: z.boolean(),
@@ -1404,19 +1399,73 @@ export const browserLiveStateSchema = z
export type BrowserLiveState = z.infer<typeof browserLiveStateSchema>
export const browserTabSummarySchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema,
primary: z.boolean(),
status: browserStatusSchema.exclude(['stopped']),
isLoading: z.boolean(),
canGoBack: z.boolean(),
url: z.string().max(8_192).optional(),
createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative()
})
.strict()
export type BrowserTabSummary = z.infer<typeof browserTabSummarySchema>
export const browserCreateTabRequestSchema = z
.object({
conversationId: conversationIdSchema,
workbarInstanceId: browserWorkbarInstanceIdSchema
})
.strict()
export type BrowserCreateTabRequest = z.infer<typeof browserCreateTabRequestSchema>
export const browserListTabsRequestSchema = z
.object({ conversationId: conversationIdSchema })
.strict()
export type BrowserListTabsRequest = z.infer<typeof browserListTabsRequestSchema>
export const browserTabRequestSchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema
})
.strict()
export type BrowserTabRequest = z.infer<typeof browserTabRequestSchema>
export const browserCloseTabRequestSchema = browserTabRequestSchema
export type BrowserCloseTabRequest = BrowserTabRequest
export const browserStopRequestSchema = z
.object({
conversationId: conversationIdSchema
})
.strict()
export const browserBackRequestSchema = browserStopRequestSchema
export const browserReloadRequestSchema = browserStopRequestSchema
export const browserStopLoadingRequestSchema = browserStopRequestSchema
const browserCompatibleTabRequestSchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema.optional()
})
.strict()
export const browserBackTabRequestSchema = browserCompatibleTabRequestSchema
export const browserReloadTabRequestSchema = browserCompatibleTabRequestSchema
export const browserStopLoadingTabRequestSchema = browserCompatibleTabRequestSchema
export const browserBackRequestSchema = browserBackTabRequestSchema
export const browserReloadRequestSchema = browserReloadTabRequestSchema
export const browserStopLoadingRequestSchema = browserStopLoadingTabRequestSchema
export const browserSnapshotRequestSchema = browserCompatibleTabRequestSchema
export const browserScreenshotRequestSchema = browserCompatibleTabRequestSchema
export const browserSetViewportRequestSchema = z
.object({
conversationId: conversationIdSchema.optional(),
tabId: browserTabIdSchema.optional(),
leaseToken: browserViewportLeaseTokenSchema,
bounds: z
.object({
x: z.number().int().nonnegative(),
@@ -1429,7 +1478,9 @@ export const browserSetViewportRequestSchema = z
})
.strict()
.refine(
(value) => Boolean(value.conversationId) === Boolean(value.bounds),
(value) =>
(!value.conversationId && !value.tabId && !value.bounds) ||
Boolean(value.conversationId && value.bounds),
'浏览器视口参数不完整'
)
@@ -1440,10 +1491,90 @@ export type BrowserViewportBounds = NonNullable<
export const browserNavigateRequestSchema = z
.object({
conversationId: conversationIdSchema,
tabId: browserTabIdSchema.optional(),
url: z.string().min(1).max(8_192)
})
.strict()
export type BrowserNavigateRequest = Omit<
z.infer<typeof browserNavigateRequestSchema>,
'tabId'
> & { tabId: BrowserTabId }
export const browserClickRequestSchema = browserCompatibleTabRequestSchema
.extend({ ref: z.string().min(1).max(256) })
.strict()
export const browserTypeRequestSchema = browserCompatibleTabRequestSchema
.extend({
ref: z.string().min(1).max(256),
text: z.string().max(16_384)
})
.strict()
export const browserSelectRequestSchema = browserCompatibleTabRequestSchema
.extend({
ref: z.string().min(1).max(256),
value: z.string().max(1_024)
})
.strict()
type BrowserOwnedRequest<T extends { tabId?: BrowserTabId }> = Omit<T, 'tabId'> & {
tabId: BrowserTabId
}
export type BrowserBackRequest = BrowserOwnedRequest<
z.infer<typeof browserBackTabRequestSchema>
>
export type BrowserReloadRequest = BrowserOwnedRequest<
z.infer<typeof browserReloadTabRequestSchema>
>
export type BrowserStopLoadingRequest = BrowserOwnedRequest<
z.infer<typeof browserStopLoadingTabRequestSchema>
>
export type BrowserSnapshotRequest = BrowserOwnedRequest<
z.infer<typeof browserSnapshotRequestSchema>
>
export type BrowserScreenshotRequest = BrowserOwnedRequest<
z.infer<typeof browserScreenshotRequestSchema>
>
export type BrowserClickRequest = BrowserOwnedRequest<
z.infer<typeof browserClickRequestSchema>
>
export type BrowserTypeRequest = BrowserOwnedRequest<
z.infer<typeof browserTypeRequestSchema>
>
export type BrowserSelectRequest = BrowserOwnedRequest<
z.infer<typeof browserSelectRequestSchema>
>
export type BrowserSetViewportRequest =
| {
conversationId: string
tabId: BrowserTabId
bounds: BrowserViewportBounds
leaseToken: string
}
| { leaseToken: string }
export type BrowserSnapshotResult = {
url: string
title: string
nodes: Array<{
ref: string
role: string
name: string
value?: string
disabled?: boolean
focused?: boolean
editable?: boolean
}>
truncated: boolean
}
export type BrowserScreenshotResult = {
type: 'image'
mimeType: 'image/jpeg'
data: string
}
export const knowledgeIdSchema = z.string().uuid()
export const knowledgeCreateSchema = z
.object({
@@ -1637,14 +1768,31 @@ export type DesktopApi = {
onEvent: (listener: (event: AgentEvent) => void) => () => void
}
browser: {
navigate: (conversationId: string, url: string) => Promise<void>
back: (conversationId: string) => Promise<void>
reload: (conversationId: string) => Promise<void>
stopLoading: (conversationId: string) => Promise<void>
setViewport: (
conversationId?: string,
bounds?: BrowserViewportBounds
) => Promise<void>
createTab?: (request: BrowserCreateTabRequest) => Promise<BrowserTabSummary>
listTabs?: (request: BrowserListTabsRequest) => Promise<BrowserTabSummary[]>
closeTab?: (request: BrowserCloseTabRequest) => Promise<void>
navigate: {
(request: BrowserNavigateRequest): Promise<void>
(conversationId: string, url: string): Promise<void>
}
back: {
(request: BrowserBackRequest): Promise<void>
(conversationId: string): Promise<void>
}
reload: {
(request: BrowserReloadRequest): Promise<void>
(conversationId: string): Promise<void>
}
stopLoading: {
(request: BrowserStopLoadingRequest): Promise<void>
(conversationId: string): Promise<void>
}
snapshot?: (request: BrowserSnapshotRequest) => Promise<BrowserSnapshotResult>
click?: (request: BrowserClickRequest) => Promise<void>
type?: (request: BrowserTypeRequest) => Promise<void>
select?: (request: BrowserSelectRequest) => Promise<void>
screenshot?: (request: BrowserScreenshotRequest) => Promise<BrowserScreenshotResult>
setViewport: (request: BrowserSetViewportRequest) => Promise<void>
stop: (conversationId: string) => Promise<void>
onState: (listener: (state: BrowserLiveState) => void) => () => void
}
+8
View File
@@ -22,10 +22,18 @@ export const ipcChannels = {
agentQuestionRespond: 'agent:question:respond',
agentCompactConversation: 'agent:context:compact',
agentEvent: 'agent:event',
browserCreateTab: 'browser:tab:create',
browserListTabs: 'browser:tab:list',
browserCloseTab: 'browser:tab:close',
browserNavigate: 'browser:navigate',
browserBack: 'browser:back',
browserReload: 'browser:reload',
browserStopLoading: 'browser:stop-loading',
browserSnapshot: 'browser:snapshot',
browserClick: 'browser:click',
browserType: 'browser:type',
browserSelect: 'browser:select',
browserScreenshot: 'browser:screenshot',
browserSetViewport: 'browser:set-viewport',
browserStop: 'browser:stop',
browserState: 'browser:state',
+26 -51
View File
@@ -10,7 +10,7 @@ export const agentRuntimeSelectionSchema = z.discriminatedUnion(
z
.object({
provider: z.literal('model'),
profileId: runtimeSelectionProfileIdSchema
profileId: runtimeSelectionProfileIdSchema.optional()
})
.strict(),
z
@@ -48,17 +48,21 @@ export type RuntimeSelectionRepairSettings = {
}>
defaultModelProfileId: string
opencodeModelSource:
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
continueModelSource:
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
deepseekHarnessModelSource?:
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
}
type RuntimeModelSource =
| { kind: 'default' }
| { kind: 'platform' }
| { kind: 'profile'; profileId: string }
@@ -79,34 +83,9 @@ type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][numbe
export function getRuntimeSelectionForProvider(
provider: Exclude<AgentRuntimeSelection['provider'], 'auto'>,
settings: RuntimeSelectionDefaultSettings
_settings: RuntimeSelectionDefaultSettings
): AgentRuntimeSelection {
if (provider === 'model') {
return {
provider,
profileId: settings.defaultModelProfileId
}
}
const source =
provider === 'opencode'
? settings.opencodeModelSource
: provider === 'continue'
? settings.continueModelSource
: settings.deepseekHarnessModelSource
const resolvedProfile =
provider === 'opencode'
? settings.opencodeModelProfile
: provider === 'continue'
? settings.continueModelProfile
: settings.deepseekHarnessModelProfile
return {
provider,
...(source?.kind === 'profile'
? { profileId: source.profileId }
: !source && resolvedProfile
? { profileId: resolvedProfile.id }
: {})
}
return { provider }
}
export function getDefaultRuntimeSelection(
@@ -121,6 +100,20 @@ export function getDefaultRuntimeSelection(
: getRuntimeSelectionForProvider('model', settings)
}
// Resolve only for presentation or request diagnostics, never for persisted defaults.
export function getRuntimeSelectionProfileId(
selection: AgentRuntimeSelection,
settings: RuntimeSelectionDefaultSettings
): string | undefined {
if (selection.provider === 'auto') return undefined
if (selection.profileId) return selection.profileId
if (selection.provider === 'model') return settings.defaultModelProfileId
const source = selection.provider === 'opencode' ? settings.opencodeModelSource
: selection.provider === 'continue' ? settings.continueModelSource : settings.deepseekHarnessModelSource
return source?.kind === 'default' ? settings.defaultModelProfileId
: source?.kind === 'profile' ? source.profileId : undefined
}
export function isChannelModelProfileUsable(
profile: ChannelModelProfile
): boolean {
@@ -162,11 +155,7 @@ export function repairChannelRuntimeSelection(
isChannelModelProfileUsable(profile)
) ??
settings.modelProfiles.find(isChannelModelProfileUsable)
const defaultDirectSelection: AgentRuntimeSelection = {
provider: 'model',
profileId:
defaultDirectProfile?.id ?? settings.defaultModelProfileId
}
const defaultDirectSelection: AgentRuntimeSelection = { provider: 'model' }
if (selection.provider === 'auto') {
return defaultDirectSelection
}
@@ -196,7 +185,7 @@ export function repairChannelRuntimeSelection(
return repaired
}
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === repaired.profileId
(candidate) => candidate.id === (repaired.profileId ?? defaultDirectProfile?.id)
)
return profile && isChannelModelProfileUsable(profile)
? repaired
@@ -217,29 +206,15 @@ export function repairAgentRuntimeSelection(
return selection
}
if (selection.provider === 'model') {
return {
provider: 'model',
profileId: settings.defaultModelProfileId
}
}
const source =
selection.provider === 'opencode'
? settings.opencodeModelSource
: selection.provider === 'continue'
? settings.continueModelSource
: settings.deepseekHarnessModelSource ?? { kind: 'platform' }
return {
provider: selection.provider,
...(source.kind === 'profile'
? { profileId: source.profileId }
: {})
return { provider: 'model' }
}
return { provider: selection.provider }
}
export function agentRuntimeSelectionKey(
selection: AgentRuntimeSelection
): string {
return `${selection.provider}:${'profileId' in selection
? selection.profileId ?? 'platform'
? selection.profileId ?? 'default'
: 'default'}`
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { workbarTargetRefSchema } from './workbar-contracts'
import { workbarExecutionTargetRefSchema } from './workbar-contracts'
export const TERMINAL_LIMITS = {
maximumSessionsPerWindow: 12,
@@ -35,7 +35,7 @@ export type TerminalSessionId = z.infer<
typeof terminalSessionIdSchema
>
export const terminalTargetSchema = workbarTargetRefSchema
export const terminalTargetSchema = workbarExecutionTargetRefSchema
export type TerminalTarget = z.infer<typeof terminalTargetSchema>
export const terminalSessionStateSchema = z.enum([
+116 -9
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
WORKBAR_APP_DEFINITIONS,
WORKBAR_LIMITS,
normalizeWorkbarLayoutPreferences,
workbarAppDefinitionSchema,
workbarLayoutPreferencesSchema,
workbarTabInstanceSchema
@@ -14,20 +15,28 @@ const projectId = '00000000-0000-4000-8000-000000000201'
describe('workbar contracts', () => {
it('freezes the first application catalog and instance policies', () => {
expect(
WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultOpen }) => ({
WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultContext, defaultOpen, required, closable, reorderable }) => ({
id,
instancePolicy,
defaultOpen
defaultContext,
defaultOpen,
required,
closable,
reorderable
}))
).toEqual([
{ id: 'tasks', instancePolicy: 'single', defaultOpen: true },
{ id: 'workspace', instancePolicy: 'single', defaultOpen: true },
{ id: 'browser', instancePolicy: 'single', defaultOpen: true },
{ id: 'results', instancePolicy: 'single', defaultOpen: true },
{ id: 'tasks', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true },
{ id: 'workspace', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true },
{ id: 'browser', instancePolicy: 'multiple', defaultContext: 'current-conversation', defaultOpen: true, required: false, closable: true, reorderable: true },
{ id: 'results', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: false, closable: true, reorderable: true },
{
id: 'terminal',
instancePolicy: 'multiple',
defaultOpen: false
defaultContext: 'current-project',
defaultOpen: false,
required: false,
closable: true,
reorderable: true
}
])
for (const definition of WORKBAR_APP_DEFINITIONS) {
@@ -51,12 +60,52 @@ describe('workbar contracts', () => {
activeInstanceId: terminalId,
expanded: true,
dock: 'right',
widthRatio: 0.35
widthRatio: 0.35,
taskScope: 'current-project'
} as const
expect(workbarLayoutPreferencesSchema.parse(layout)).toEqual(layout)
})
it('persists a browser conversation binding without a runtime tab identifier', () => {
expect(
workbarTabInstanceSchema.parse({
id: '00000000-0000-4000-8000-000000000103',
appId: 'browser',
title: '浏览器 · 会话 A',
targetRef: { type: 'conversation', conversationId: 'conversation-a' }
})
).toEqual({
id: '00000000-0000-4000-8000-000000000103',
appId: 'browser',
title: '浏览器 · 会话 A',
targetRef: { type: 'conversation', conversationId: 'conversation-a' }
})
expect(
workbarTabInstanceSchema.safeParse({
id: '00000000-0000-4000-8000-000000000103',
appId: 'browser',
title: '浏览器',
targetRef: { type: 'conversation', conversationId: 'conversation-a' },
tabId: '00000000-0000-4000-8000-000000000999'
}).success
).toBe(false)
})
it('explicitly keeps every existing app visible across context switches', () => {
for (const definition of WORKBAR_APP_DEFINITIONS) {
expect(definition.visibleAcrossContextSwitches).toBe(true)
const { visibleAcrossContextSwitches, ...withoutVisibility } = definition
expect(workbarAppDefinitionSchema.safeParse(withoutVisibility).success).toBe(false)
expect(
workbarAppDefinitionSchema.parse({
...withoutVisibility,
visibleAcrossContextSwitches
}).visibleAcrossContextSwitches
).toBe(true)
}
})
it('rejects unknown and sensitive target parameters', () => {
for (const targetRef of [
{ type: 'local', cwd: 'C:\\secret' },
@@ -102,7 +151,8 @@ describe('workbar contracts', () => {
activeInstanceId: taskId,
expanded: true,
dock: 'right',
widthRatio: 0.4
widthRatio: 0.4,
taskScope: 'current-project'
}
expect(
workbarLayoutPreferencesSchema.safeParse({
@@ -131,4 +181,61 @@ describe('workbar contracts', () => {
}).success
).toBe(false)
})
it('normalizes duplicate policies, fixed defaults, and stale active state', () => {
const browserId = '00000000-0000-4000-8000-000000000103'
const secondBrowserId = '00000000-0000-4000-8000-000000000104'
const workspaceId = '00000000-0000-4000-8000-000000000105'
const normalized = normalizeWorkbarLayoutPreferences(
{
instances: [
{ id: browserId, appId: 'browser', title: '浏览器' },
{ id: secondBrowserId, appId: 'browser', title: '浏览器 2' },
{ id: workspaceId, appId: 'workspace', title: '工作区' },
{ id: terminalId, appId: 'workspace', title: '重复工作区' }
],
activeInstanceId: terminalId,
expanded: true,
dock: 'right',
widthRatio: 0.4,
taskScope: 'all-projects'
},
[
{ id: taskId, appId: 'tasks', title: '任务中心' },
{ id: workspaceId, appId: 'workspace', title: '工作区' }
]
)
expect(normalized?.instances.map(({ id, appId }) => ({ id, appId })))
.toEqual([
{ id: taskId, appId: 'tasks' },
{ id: browserId, appId: 'browser' },
{ id: secondBrowserId, appId: 'browser' },
{ id: workspaceId, appId: 'workspace' }
])
expect(normalized?.activeInstanceId).toBe(taskId)
expect(normalized?.taskScope).toBe('all-projects')
})
it('preserves a valid empty layout with a null active instance', () => {
expect(
normalizeWorkbarLayoutPreferences(
{
instances: [],
activeInstanceId: null,
expanded: false,
dock: 'right',
widthRatio: 0.4
},
[]
)
).toEqual({
instances: [],
activeInstanceId: null,
expanded: false,
dock: 'right',
widthRatio: 0.4,
taskScope: 'current-project'
})
})
})
+153 -7
View File
@@ -38,7 +38,8 @@ export type WorkbarInstancePolicy = z.infer<
export const workbarDefaultContextSchema = z.enum([
'application',
'current-project'
'current-project',
'current-conversation'
])
export type WorkbarDefaultContext = z.infer<
typeof workbarDefaultContextSchema
@@ -78,7 +79,11 @@ export const workbarAppDefinitionSchema = z
),
instancePolicy: workbarInstancePolicySchema,
defaultContext: workbarDefaultContextSchema,
visibleAcrossContextSwitches: z.boolean(),
defaultOpen: z.boolean(),
required: z.boolean(),
closable: z.boolean(),
reorderable: z.boolean(),
availability: workbarAvailabilitySchema
})
.strict()
@@ -93,57 +98,77 @@ export type WorkbarAppDefinition = z.infer<
export const WORKBAR_APP_DEFINITIONS = [
{
id: 'tasks',
visibleAcrossContextSwitches: true,
label: '任务中心',
icon: 'tasks',
description: '查看和管理当前任务。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
required: true,
closable: false,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'workspace',
visibleAcrossContextSwitches: true,
label: '工作区',
icon: 'workspace',
description: '查看当前项目的工作区。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
required: true,
closable: false,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'browser',
visibleAcrossContextSwitches: true,
label: '浏览器',
icon: 'browser',
description: '浏览任务相关内容。',
instancePolicy: 'single',
defaultContext: 'application',
instancePolicy: 'multiple',
defaultContext: 'current-conversation',
defaultOpen: true,
required: false,
closable: true,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'results',
visibleAcrossContextSwitches: true,
label: '成果',
icon: 'results',
description: '查看任务生成的成果。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
required: false,
closable: true,
reorderable: true,
availability: { state: 'available' }
},
{
id: 'terminal',
visibleAcrossContextSwitches: true,
label: '终端',
icon: 'terminal',
description: '打开当前项目的用户终端。',
instancePolicy: 'multiple',
defaultContext: 'current-project',
defaultOpen: false,
required: false,
closable: true,
reorderable: true,
availability: { state: 'available' }
}
] as const satisfies readonly WorkbarAppDefinition[]
export const workbarTargetRefSchema = z.discriminatedUnion('type', [
export const workbarExecutionTargetRefSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('local') }).strict(),
z
.object({
@@ -152,6 +177,19 @@ export const workbarTargetRefSchema = z.discriminatedUnion('type', [
})
.strict()
])
export type WorkbarExecutionTargetRef = z.infer<
typeof workbarExecutionTargetRefSchema
>
export const workbarTargetRefSchema = z.union([
workbarExecutionTargetRefSchema,
z
.object({
type: z.literal('conversation'),
conversationId: z.string().min(1).max(128)
})
.strict()
])
export type WorkbarTargetRef = z.infer<
typeof workbarTargetRefSchema
>
@@ -165,13 +203,37 @@ export const workbarTabInstanceSchema = z
})
.strict()
.superRefine((instance, context) => {
if (instance.appId === 'terminal' && !instance.targetRef) {
if (
instance.appId === 'terminal' &&
(!instance.targetRef || instance.targetRef.type === 'conversation')
) {
context.addIssue({
code: 'custom',
path: ['targetRef'],
message: 'Terminal instances require a public target reference'
})
}
if (
instance.appId === 'browser' &&
instance.targetRef &&
instance.targetRef.type !== 'conversation'
) {
context.addIssue({
code: 'custom',
path: ['targetRef'],
message: 'Browser instances only accept conversation bindings'
})
}
if (
instance.appId !== 'browser' &&
instance.targetRef?.type === 'conversation'
) {
context.addIssue({
code: 'custom',
path: ['targetRef'],
message: 'Conversation bindings are reserved for browser instances'
})
}
})
export type WorkbarTabInstance = z.infer<
typeof workbarTabInstanceSchema
@@ -183,7 +245,14 @@ const singleInstanceAppIds = new Set<WorkbarAppId>(
).map((definition) => definition.id)
)
export const workbarLayoutPreferencesSchema = z
export const workbarTaskScopeSchema = z.enum([
'current-project',
'global',
'all-projects'
])
export type WorkbarTaskScope = z.infer<typeof workbarTaskScopeSchema>
const workbarLayoutShapeSchema = z
.object({
instances: z
.array(workbarTabInstanceSchema)
@@ -191,9 +260,12 @@ export const workbarLayoutPreferencesSchema = z
activeInstanceId: z.string().uuid().nullable(),
expanded: z.boolean(),
dock: z.literal('right'),
widthRatio: z.number().finite().gt(0).lt(1)
widthRatio: z.number().finite().gt(0).lt(1),
taskScope: workbarTaskScopeSchema.default('current-project')
})
.strict()
export const workbarLayoutPreferencesSchema = workbarLayoutShapeSchema
.superRefine((layout, context) => {
const instanceIds = new Set<string>()
const seenSingleApps = new Set<WorkbarAppId>()
@@ -244,3 +316,77 @@ export const workbarLayoutPreferencesSchema = z
export type WorkbarLayoutPreferences = z.infer<
typeof workbarLayoutPreferencesSchema
>
export function normalizeWorkbarLayoutPreferences(
value: unknown,
defaultInstances: readonly WorkbarTabInstance[]
): WorkbarLayoutPreferences | undefined {
const parsed = workbarLayoutShapeSchema.safeParse(value)
if (!parsed.success) {
return undefined
}
const instanceIds = new Set<string>()
const seenSingleApps = new Set<WorkbarAppId>()
const instances: WorkbarTabInstance[] = []
for (const instance of parsed.data.instances) {
if (
instanceIds.has(instance.id) ||
(singleInstanceAppIds.has(instance.appId) &&
seenSingleApps.has(instance.appId))
) {
continue
}
instanceIds.add(instance.id)
if (singleInstanceAppIds.has(instance.appId)) {
seenSingleApps.add(instance.appId)
}
instances.push(instance)
}
for (const defaultInstance of defaultInstances) {
const definition = WORKBAR_APP_DEFINITIONS.find(
(candidate) => candidate.id === defaultInstance.appId
)
if (
definition?.required !== true ||
instances.some(
(instance) => instance.appId === defaultInstance.appId
)
) {
continue
}
const collidingIdIndex = instances.findIndex(
(instance) => instance.id === defaultInstance.id
)
if (collidingIdIndex >= 0) {
const [removed] = instances.splice(collidingIdIndex, 1)
if (removed && singleInstanceAppIds.has(removed.appId)) {
seenSingleApps.delete(removed.appId)
}
}
instances.splice(
WORKBAR_APP_DEFINITIONS.findIndex(
(candidate) => candidate.id === defaultInstance.appId
),
0,
defaultInstance
)
}
const boundedInstances = instances.slice(
0,
WORKBAR_LIMITS.maximumOpenInstances
)
const activeInstanceId = boundedInstances.some(
(instance) => instance.id === parsed.data.activeInstanceId
)
? parsed.data.activeInstanceId
: boundedInstances[0]?.id ?? null
return workbarLayoutPreferencesSchema.parse({
...parsed.data,
instances: boundedInstances,
activeInstanceId
})
}