feat: add multi-terminal workbar and resizable sidebars

The assistant workbar now uses persistent application tabs with a permanent catalog entry point and supports multiple local or managed SSH terminals bound to the current project. Terminal sessions flow through the Main, preload, and renderer boundaries with bounded output, resize, lifecycle, and cleanup behavior.

The main sidebar and Magic Notes list now support pointer and keyboard resizing. Magic Notes can also hide and restore its list, remembers the chosen layout, and stacks its panes when the available width is narrow.

Closing a terminal tab ends its Shell session. Restored terminal tabs do not restart prior Shell processes and require an explicit reconnect.

Release note: 助手工作栏现可通过“+”打开多个本机或当前远程项目终端;主侧栏和魔法笔记列表支持拖拽调宽,魔法笔记列表可隐藏并记住布局。
This commit is contained in:
mesalogo
2026-09-01 00:49:49 +08:00
parent 73cc55bf75
commit 27055693bf
45 changed files with 8814 additions and 192 deletions
+21
View File
@@ -150,6 +150,16 @@ import type {
FeedbackSubmitInput,
FeedbackSubmitResult
} from './feedback-contracts'
import type {
TerminalAckRequest,
TerminalCloseRequest,
TerminalCreateRequest,
TerminalEvent,
TerminalResizeRequest,
TerminalSnapshot,
TerminalSnapshotRequest,
TerminalWriteRequest
} from './terminal-contracts'
export type {
KnowledgeTaskError,
KnowledgeTaskItem,
@@ -1548,6 +1558,17 @@ export type DesktopApi = {
stop: (conversationId: string) => Promise<void>
onState: (listener: (state: BrowserLiveState) => void) => () => void
}
terminal: {
create: (request: TerminalCreateRequest) => Promise<TerminalSnapshot>
write: (request: TerminalWriteRequest) => Promise<void>
resize: (request: TerminalResizeRequest) => Promise<void>
close: (request: TerminalCloseRequest) => Promise<TerminalSnapshot>
getSnapshot: (
request: TerminalSnapshotRequest
) => Promise<TerminalSnapshot>
ack: (request: TerminalAckRequest) => Promise<void>
onEvent: (listener: (event: TerminalEvent) => void) => () => void
}
settings: {
getRuntime: () => Promise<RuntimeSettings>
updateRuntime: (input: RuntimeSettingsInput) => Promise<RuntimeSettings>
+7
View File
@@ -23,6 +23,13 @@ export const ipcChannels = {
browserInteract: 'browser:interact',
browserStop: 'browser:stop',
browserState: 'browser:state',
terminalCreate: 'terminal:create',
terminalWrite: 'terminal:write',
terminalResize: 'terminal:resize',
terminalClose: 'terminal:close',
terminalSnapshot: 'terminal:snapshot',
terminalAck: 'terminal:ack',
terminalEvent: 'terminal:event',
runtimeSettingsGet: 'settings:runtime:get',
runtimeSettingsUpdate: 'settings:runtime:update',
runtimeSettingsSelectWorkspace: 'settings:runtime:select-workspace',
+194
View File
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest'
import {
TERMINAL_LIMITS,
terminalAckRequestSchema,
terminalCreateRequestSchema,
terminalEventSchema,
terminalResizeRequestSchema,
terminalSnapshotSchema,
terminalWriteRequestSchema
} from './terminal-contracts'
const sessionId = '00000000-0000-4000-8000-000000000101'
const projectId = '00000000-0000-4000-8000-000000000201'
describe('terminal contracts', () => {
it('accepts bounded create, write, resize, snapshot, event, and ACK data', () => {
expect(
terminalCreateRequestSchema.parse({
target: { type: 'project', projectId },
cols: 120,
rows: 30
})
).toEqual({
target: { type: 'project', projectId },
cols: 120,
rows: 30
})
expect(
terminalWriteRequestSchema.parse({ sessionId, data: '你好\r' })
).toEqual({ sessionId, data: '你好\r' })
expect(
terminalResizeRequestSchema.parse({
sessionId,
cols: 80,
rows: 24
})
).toBeTruthy()
expect(
terminalSnapshotSchema.parse({
sessionId,
target: { type: 'project', projectId },
targetLabel: '构建主机',
title: '终端 · 构建主机 1',
state: 'running',
shell: 'bash',
workingDirectory: '/home/builder',
size: { cols: 120, rows: 30 },
lastSequence: 3,
exit: null,
error: null
})
).toBeTruthy()
expect(
terminalEventSchema.parse({
type: 'output',
sessionId,
sequence: 4,
data: '\u001b[32mdone\u001b[0m\r\n'
})
).toBeTruthy()
expect(
terminalAckRequestSchema.parse({ sessionId, sequence: 4 })
).toEqual({ sessionId, sequence: 4 })
})
it('uses UTF-8 byte limits for input and output chunks', () => {
expect(
terminalWriteRequestSchema.safeParse({
sessionId,
data: '界'.repeat(
Math.floor(TERMINAL_LIMITS.maximumInputBytes / 3)
)
}).success
).toBe(true)
expect(
terminalWriteRequestSchema.safeParse({
sessionId,
data: '界'.repeat(
Math.floor(TERMINAL_LIMITS.maximumInputBytes / 3) + 1
)
}).success
).toBe(false)
expect(
terminalEventSchema.safeParse({
type: 'output',
sessionId,
sequence: 1,
data: 'x'.repeat(TERMINAL_LIMITS.maximumEventBytes + 1)
}).success
).toBe(false)
})
it('enforces session, sequence, and terminal dimension boundaries', () => {
for (const size of [
{ cols: TERMINAL_LIMITS.minimumColumns - 1, rows: 24 },
{ cols: TERMINAL_LIMITS.maximumColumns + 1, rows: 24 },
{ cols: 80, rows: TERMINAL_LIMITS.minimumRows - 1 },
{ cols: 80, rows: TERMINAL_LIMITS.maximumRows + 1 },
{ cols: 80.5, rows: 24 }
]) {
expect(
terminalResizeRequestSchema.safeParse({
sessionId,
...size
}).success
).toBe(false)
}
expect(
terminalWriteRequestSchema.safeParse({
sessionId: 'renderer-selected-pid-123',
data: 'pwd\r'
}).success
).toBe(false)
expect(
terminalEventSchema.safeParse({
type: 'state',
sessionId,
sequence: 0,
state: 'running'
}).success
).toBe(false)
expect(
terminalAckRequestSchema.safeParse({
sessionId,
sequence: -1
}).success
).toBe(false)
})
it('strictly rejects shell, path, command, environment, and credentials', () => {
for (const extra of [
{ shell: 'C:\\custom\\shell.exe' },
{ cwd: 'C:\\private' },
{ command: 'curl example.invalid' },
{ environment: { MODEL_API_KEY: 'secret' } },
{ password: 'secret' },
{ privateKey: 'secret' },
{ pid: 42 }
]) {
expect(
terminalCreateRequestSchema.safeParse({
target: { type: 'local' },
cols: 80,
rows: 24,
...extra
}).success
).toBe(false)
}
expect(
terminalCreateRequestSchema.safeParse({
target: {
type: 'project',
projectId,
hostname: 'host.internal',
username: 'builder'
},
cols: 80,
rows: 24
}).success
).toBe(false)
})
it('requires coherent exited snapshots and strict event variants', () => {
const snapshot = {
sessionId,
target: { type: 'local' },
targetLabel: '本机',
title: '终端 · 本机 1',
state: 'exited',
shell: 'PowerShell 7',
workingDirectory: 'C:\\Users\\tester',
size: { cols: 80, rows: 24 },
lastSequence: 2,
exit: null,
error: null
} as const
expect(terminalSnapshotSchema.safeParse(snapshot).success).toBe(false)
expect(
terminalSnapshotSchema.safeParse({
...snapshot,
exit: { exitCode: 0, signal: null }
}).success
).toBe(true)
expect(
terminalEventSchema.safeParse({
type: 'output',
sessionId,
sequence: 1,
data: 'ok',
state: 'running'
}).success
).toBe(false)
})
})
+254
View File
@@ -0,0 +1,254 @@
import { z } from 'zod'
import { workbarTargetRefSchema } from './workbar-contracts'
export const TERMINAL_LIMITS = {
maximumSessionsPerWindow: 12,
minimumColumns: 2,
maximumColumns: 500,
minimumRows: 1,
maximumRows: 200,
maximumTitleBytes: 256,
maximumShellLabelBytes: 256,
maximumTargetLabelBytes: 512,
maximumWorkingDirectoryBytes: 4 * 1024,
maximumInputBytes: 64 * 1024,
maximumEventBytes: 64 * 1024,
maximumPendingEvents: 256,
maximumBufferedOutputBytes: 4 * 1024 * 1024,
maximumErrorMessageBytes: 2 * 1024
} as const
const boundedUtf8StringSchema = (
maximumBytes: number,
minimumBytes = 0
): z.ZodType<string> =>
z.string().refine(
(value) => {
const size = new TextEncoder().encode(value).byteLength
return size >= minimumBytes && size <= maximumBytes
},
`Value must contain ${minimumBytes}-${maximumBytes} UTF-8 bytes`
)
export const terminalSessionIdSchema = z.string().uuid()
export type TerminalSessionId = z.infer<
typeof terminalSessionIdSchema
>
export const terminalTargetSchema = workbarTargetRefSchema
export type TerminalTarget = z.infer<typeof terminalTargetSchema>
export const terminalSessionStateSchema = z.enum([
'starting',
'running',
'exited',
'interrupted',
'closing',
'failed'
])
export type TerminalSessionState = z.infer<
typeof terminalSessionStateSchema
>
export const terminalErrorCodeSchema = z.enum([
'session-limit-reached',
'target-not-found',
'target-unavailable',
'host-unverified',
'credentials-unavailable',
'interactive-shell-unsupported',
'launch-failed',
'connection-failed',
'session-not-found',
'session-not-running',
'interrupted',
'output-limit-exceeded',
'internal-error'
])
export type TerminalErrorCode = z.infer<
typeof terminalErrorCodeSchema
>
export const terminalErrorSchema = z
.object({
code: terminalErrorCodeSchema,
message: boundedUtf8StringSchema(
TERMINAL_LIMITS.maximumErrorMessageBytes,
1
),
retryable: z.boolean()
})
.strict()
export type TerminalError = z.infer<typeof terminalErrorSchema>
export const terminalSizeSchema = z
.object({
cols: z
.number()
.int()
.min(TERMINAL_LIMITS.minimumColumns)
.max(TERMINAL_LIMITS.maximumColumns),
rows: z
.number()
.int()
.min(TERMINAL_LIMITS.minimumRows)
.max(TERMINAL_LIMITS.maximumRows)
})
.strict()
export type TerminalSize = z.infer<typeof terminalSizeSchema>
export const terminalCreateRequestSchema = z
.object({
target: terminalTargetSchema,
cols: terminalSizeSchema.shape.cols,
rows: terminalSizeSchema.shape.rows
})
.strict()
export type TerminalCreateRequest = z.infer<
typeof terminalCreateRequestSchema
>
export const terminalWriteRequestSchema = z
.object({
sessionId: terminalSessionIdSchema,
data: boundedUtf8StringSchema(TERMINAL_LIMITS.maximumInputBytes, 1)
})
.strict()
export type TerminalWriteRequest = z.infer<
typeof terminalWriteRequestSchema
>
export const terminalResizeRequestSchema = z
.object({
sessionId: terminalSessionIdSchema,
cols: terminalSizeSchema.shape.cols,
rows: terminalSizeSchema.shape.rows
})
.strict()
export type TerminalResizeRequest = z.infer<
typeof terminalResizeRequestSchema
>
export const terminalCloseRequestSchema = z
.object({
sessionId: terminalSessionIdSchema
})
.strict()
export type TerminalCloseRequest = z.infer<
typeof terminalCloseRequestSchema
>
export const terminalSnapshotRequestSchema = terminalCloseRequestSchema
export type TerminalSnapshotRequest = TerminalCloseRequest
const terminalSequenceSchema = z.number().int().safe().min(0)
const terminalEventSequenceSchema = terminalSequenceSchema.min(1)
export const terminalAckRequestSchema = z
.object({
sessionId: terminalSessionIdSchema,
sequence: terminalSequenceSchema
})
.strict()
export type TerminalAckRequest = z.infer<
typeof terminalAckRequestSchema
>
export const terminalExitSchema = z
.object({
exitCode: z.number().int().safe().nullable(),
signal: boundedUtf8StringSchema(128, 1).nullable()
})
.strict()
.refine(
(exit) => exit.exitCode !== null || exit.signal !== null,
'An exit must include an exit code or signal'
)
export type TerminalExit = z.infer<typeof terminalExitSchema>
export const terminalSnapshotSchema = z
.object({
sessionId: terminalSessionIdSchema,
target: terminalTargetSchema,
targetLabel: boundedUtf8StringSchema(
TERMINAL_LIMITS.maximumTargetLabelBytes,
1
),
title: boundedUtf8StringSchema(
TERMINAL_LIMITS.maximumTitleBytes,
1
),
state: terminalSessionStateSchema,
shell: boundedUtf8StringSchema(
TERMINAL_LIMITS.maximumShellLabelBytes,
1
),
workingDirectory: boundedUtf8StringSchema(
TERMINAL_LIMITS.maximumWorkingDirectoryBytes,
1
),
size: terminalSizeSchema,
lastSequence: terminalSequenceSchema,
exit: terminalExitSchema.nullable(),
error: terminalErrorSchema.nullable()
})
.strict()
.superRefine((snapshot, context) => {
if (snapshot.state === 'exited' && snapshot.exit === null) {
context.addIssue({
code: 'custom',
path: ['exit'],
message: 'Exited terminal snapshots require exit details'
})
}
if (snapshot.state !== 'exited' && snapshot.exit !== null) {
context.addIssue({
code: 'custom',
path: ['exit'],
message: 'Only exited terminal snapshots include exit details'
})
}
})
export type TerminalSnapshot = z.infer<
typeof terminalSnapshotSchema
>
const terminalEventBase = {
sessionId: terminalSessionIdSchema,
sequence: terminalEventSequenceSchema
} as const
export const terminalEventSchema = z.discriminatedUnion('type', [
z
.object({
...terminalEventBase,
type: z.literal('output'),
data: boundedUtf8StringSchema(
TERMINAL_LIMITS.maximumEventBytes,
1
)
})
.strict(),
z
.object({
...terminalEventBase,
type: z.literal('state'),
state: terminalSessionStateSchema
})
.strict(),
z
.object({
...terminalEventBase,
type: z.literal('exit'),
exit: terminalExitSchema
})
.strict(),
z
.object({
...terminalEventBase,
type: z.literal('error'),
error: terminalErrorSchema
})
.strict()
])
export type TerminalEvent = z.infer<typeof terminalEventSchema>
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import {
WORKBAR_APP_DEFINITIONS,
WORKBAR_LIMITS,
workbarAppDefinitionSchema,
workbarLayoutPreferencesSchema,
workbarTabInstanceSchema
} from './workbar-contracts'
const taskId = '00000000-0000-4000-8000-000000000101'
const terminalId = '00000000-0000-4000-8000-000000000102'
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 }) => ({
id,
instancePolicy,
defaultOpen
}))
).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: 'terminal',
instancePolicy: 'multiple',
defaultOpen: false
}
])
for (const definition of WORKBAR_APP_DEFINITIONS) {
expect(workbarAppDefinitionSchema.parse(definition)).toEqual(
definition
)
}
})
it('accepts an ordered layout with public target references', () => {
const layout = {
instances: [
{ id: taskId, appId: 'tasks', title: '任务中心' },
{
id: terminalId,
appId: 'terminal',
title: '终端 · 项目',
targetRef: { type: 'project', projectId }
}
],
activeInstanceId: terminalId,
expanded: true,
dock: 'right',
widthRatio: 0.35
} as const
expect(workbarLayoutPreferencesSchema.parse(layout)).toEqual(layout)
})
it('rejects unknown and sensitive target parameters', () => {
for (const targetRef of [
{ type: 'local', cwd: 'C:\\secret' },
{ type: 'ssh-host', hostId: projectId, password: 'secret' },
{
type: 'project',
projectId,
environment: { MODEL_API_KEY: 'secret' }
}
]) {
expect(
workbarTabInstanceSchema.safeParse({
id: terminalId,
appId: 'terminal',
title: '终端',
targetRef
}).success
).toBe(false)
}
expect(
workbarTabInstanceSchema.safeParse({
id: taskId,
appId: 'tasks',
title: '任务中心',
privateState: {}
}).success
).toBe(false)
})
it('enforces title, count, uniqueness, and active-instance bounds', () => {
expect(
workbarTabInstanceSchema.safeParse({
id: terminalId,
appId: 'terminal',
title: 'x'.repeat(WORKBAR_LIMITS.maximumTitleBytes + 1),
targetRef: { type: 'local' }
}).success
).toBe(false)
const task = { id: taskId, appId: 'tasks', title: '任务中心' }
const base = {
instances: [task],
activeInstanceId: taskId,
expanded: true,
dock: 'right',
widthRatio: 0.4
}
expect(
workbarLayoutPreferencesSchema.safeParse({
...base,
instances: [task, { ...task, id: terminalId }]
}).success
).toBe(false)
expect(
workbarLayoutPreferencesSchema.safeParse({
...base,
activeInstanceId: terminalId
}).success
).toBe(false)
expect(
workbarLayoutPreferencesSchema.safeParse({
...base,
instances: Array.from(
{ length: WORKBAR_LIMITS.maximumOpenInstances + 1 },
(_, index) => ({
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
appId: 'terminal',
title: `终端 ${index}`,
targetRef: { type: 'local' }
})
)
}).success
).toBe(false)
})
})
+246
View File
@@ -0,0 +1,246 @@
import { z } from 'zod'
export const WORKBAR_LIMITS = {
maximumOpenInstances: 32,
maximumTitleBytes: 256,
maximumDescriptionBytes: 512,
maximumUnavailableReasonBytes: 512
} as const
const boundedUtf8TextSchema = (
maximumBytes: number,
minimumBytes = 1
): z.ZodType<string> =>
z.string().refine(
(value) => {
const size = new TextEncoder().encode(value).byteLength
return size >= minimumBytes && size <= maximumBytes
},
`Text must contain ${minimumBytes}-${maximumBytes} UTF-8 bytes`
)
export const workbarAppIdSchema = z.enum([
'tasks',
'workspace',
'browser',
'results',
'terminal'
])
export type WorkbarAppId = z.infer<typeof workbarAppIdSchema>
export const workbarInstancePolicySchema = z.enum([
'single',
'multiple'
])
export type WorkbarInstancePolicy = z.infer<
typeof workbarInstancePolicySchema
>
export const workbarDefaultContextSchema = z.enum([
'application',
'current-project'
])
export type WorkbarDefaultContext = z.infer<
typeof workbarDefaultContextSchema
>
export const workbarAvailabilitySchema = z.discriminatedUnion(
'state',
[
z.object({ state: z.literal('available') }).strict(),
z
.object({
state: z.literal('unavailable'),
reason: boundedUtf8TextSchema(
WORKBAR_LIMITS.maximumUnavailableReasonBytes
),
remedy: z.enum([
'open-project-settings',
'open-host-settings',
'open-application-settings',
'retry'
])
})
.strict()
]
)
export type WorkbarAvailability = z.infer<
typeof workbarAvailabilitySchema
>
export const workbarAppDefinitionSchema = z
.object({
id: workbarAppIdSchema,
label: boundedUtf8TextSchema(WORKBAR_LIMITS.maximumTitleBytes),
icon: workbarAppIdSchema,
description: boundedUtf8TextSchema(
WORKBAR_LIMITS.maximumDescriptionBytes
),
instancePolicy: workbarInstancePolicySchema,
defaultContext: workbarDefaultContextSchema,
defaultOpen: z.boolean(),
availability: workbarAvailabilitySchema
})
.strict()
.refine(
(definition) => definition.icon === definition.id,
'The stable icon identifier must match the application identifier'
)
export type WorkbarAppDefinition = z.infer<
typeof workbarAppDefinitionSchema
>
export const WORKBAR_APP_DEFINITIONS = [
{
id: 'tasks',
label: '任务中心',
icon: 'tasks',
description: '查看和管理当前任务。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
availability: { state: 'available' }
},
{
id: 'workspace',
label: '工作区',
icon: 'workspace',
description: '查看当前项目的工作区。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
availability: { state: 'available' }
},
{
id: 'browser',
label: '浏览器',
icon: 'browser',
description: '浏览任务相关内容。',
instancePolicy: 'single',
defaultContext: 'application',
defaultOpen: true,
availability: { state: 'available' }
},
{
id: 'results',
label: '成果',
icon: 'results',
description: '查看任务生成的成果。',
instancePolicy: 'single',
defaultContext: 'current-project',
defaultOpen: true,
availability: { state: 'available' }
},
{
id: 'terminal',
label: '终端',
icon: 'terminal',
description: '打开当前项目的用户终端。',
instancePolicy: 'multiple',
defaultContext: 'current-project',
defaultOpen: false,
availability: { state: 'available' }
}
] as const satisfies readonly WorkbarAppDefinition[]
export const workbarTargetRefSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('local') }).strict(),
z
.object({
type: z.literal('project'),
projectId: z.string().uuid()
})
.strict()
])
export type WorkbarTargetRef = z.infer<
typeof workbarTargetRefSchema
>
export const workbarTabInstanceSchema = z
.object({
id: z.string().uuid(),
appId: workbarAppIdSchema,
title: boundedUtf8TextSchema(WORKBAR_LIMITS.maximumTitleBytes),
targetRef: workbarTargetRefSchema.optional()
})
.strict()
.superRefine((instance, context) => {
if (instance.appId === 'terminal' && !instance.targetRef) {
context.addIssue({
code: 'custom',
path: ['targetRef'],
message: 'Terminal instances require a public target reference'
})
}
})
export type WorkbarTabInstance = z.infer<
typeof workbarTabInstanceSchema
>
const singleInstanceAppIds = new Set<WorkbarAppId>(
WORKBAR_APP_DEFINITIONS.filter(
(definition) => definition.instancePolicy === 'single'
).map((definition) => definition.id)
)
export const workbarLayoutPreferencesSchema = z
.object({
instances: z
.array(workbarTabInstanceSchema)
.max(WORKBAR_LIMITS.maximumOpenInstances),
activeInstanceId: z.string().uuid().nullable(),
expanded: z.boolean(),
dock: z.literal('right'),
widthRatio: z.number().finite().gt(0).lt(1)
})
.strict()
.superRefine((layout, context) => {
const instanceIds = new Set<string>()
const seenSingleApps = new Set<WorkbarAppId>()
layout.instances.forEach((instance, index) => {
if (instanceIds.has(instance.id)) {
context.addIssue({
code: 'custom',
path: ['instances', index, 'id'],
message: 'Workbar instance identifiers must be unique'
})
}
instanceIds.add(instance.id)
if (singleInstanceAppIds.has(instance.appId)) {
if (seenSingleApps.has(instance.appId)) {
context.addIssue({
code: 'custom',
path: ['instances', index, 'appId'],
message: 'Single-instance applications cannot be duplicated'
})
}
seenSingleApps.add(instance.appId)
}
})
if (
layout.activeInstanceId !== null &&
!instanceIds.has(layout.activeInstanceId)
) {
context.addIssue({
code: 'custom',
path: ['activeInstanceId'],
message: 'The active workbar instance must be open'
})
}
if (
layout.instances.length > 0 &&
layout.activeInstanceId === null
) {
context.addIssue({
code: 'custom',
path: ['activeInstanceId'],
message: 'An open workbar layout requires an active instance'
})
}
})
export type WorkbarLayoutPreferences = z.infer<
typeof workbarLayoutPreferencesSchema
>