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
+1 -1
View File
@@ -1,4 +1,4 @@
const runtimeProviderEnvironmentNames = [
export const runtimeProviderEnvironmentNames = [
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'GOOGLE_GENERATIVE_AI_API_KEY',
+24 -1
View File
@@ -156,6 +156,7 @@ import {
DesktopDiagnostics,
type DesktopDiagnosticFailureObserver
} from './desktop-diagnostics'
import { TerminalSessionManager } from './terminal/terminal-session-manager'
const legacyDefaultShortcut =
defaultGlobalShortcutSettings.accelerator
@@ -215,6 +216,7 @@ const embeddingBrokers = new Set<EmbeddingInferenceBroker>()
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
let remoteAgentServices: RemoteAgentServices | undefined
let terminalSessionManager: TerminalSessionManager | undefined
let managedRemoteExecutionServices:
| ManagedRemoteExecutionServices
| undefined
@@ -1302,6 +1304,25 @@ if (hasSingleInstanceLock) {
await Promise.all(invalidations)
}
)
terminalSessionManager = new TerminalSessionManager({
database: startupAssistantDatabase,
executionSpaceResolver,
targetResolver: startupRemoteAgentServices.targetResolver,
sshPool: startupRemoteAgentServices.sshPool,
remoteEnabled: async () =>
(await applicationSettingsStore.get()).remoteProjectsEnabled,
deliverEvent: (ownerWebContentsId, event) => {
const targetWindow = mainWindow
if (
targetWindow &&
!targetWindow.isDestroyed() &&
targetWindow.webContents.id === ownerWebContentsId &&
!targetWindow.webContents.isDestroyed()
) {
targetWindow.webContents.send(ipcChannels.terminalEvent, event)
}
}
})
removeIpcHandlers = registerIpcHandlers(
mainWindow,
runtime,
@@ -1359,7 +1380,8 @@ if (hasSingleInstanceLock) {
setCurrentEmbeddingConnection,
remoteEnvironmentUpdateService,
agentPackageManager,
startupRemoteAgentServices.connectionManager
startupRemoteAgentServices.connectionManager,
terminalSessionManager
)
removeFeedbackIpcHandler = registerFeedbackIpcHandler(
mainWindow,
@@ -1473,6 +1495,7 @@ app.on('before-quit', (event) => {
() => documentOcrModelManager?.dispose(),
() => documentOcrBroker?.dispose()
],
[() => terminalSessionManager?.dispose()],
[() => managedRemoteExecutionServices?.dispose()],
[() => remoteAgentServices?.dispose()],
[() => knowledgeGateway?.dispose()],
+190
View File
@@ -55,6 +55,196 @@ describe('embedding IPC boundary', () => {
})
})
describe('terminal IPC boundary', () => {
afterEach(() => {
electronMocks.handlers.clear()
vi.clearAllMocks()
})
it('validates terminal requests and routes them through the trusted renderer owner', async () => {
const webContents = {
id: 73,
mainFrame: { url: 'file:///goodbuddy/index.html' },
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
isDestroyed: vi.fn(() => false),
send: vi.fn()
}
const window = {
webContents,
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
on: vi.fn(),
removeListener: vi.fn()
}
const snapshot = {
sessionId: '00000000-0000-4000-8000-000000000073',
target: { type: 'local' as const },
title: 'PowerShell',
state: 'running' as const,
cwd: 'C:\\Workspace',
cols: 120,
rows: 36,
lastSequence: 0
}
const terminalSessionManager = {
create: vi.fn(async () => snapshot),
enableEventDelivery: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
snapshot: vi.fn(() => snapshot),
acknowledge: vi.fn(),
close: vi.fn(async () => ({
...snapshot,
state: 'closed' as const
})),
closeOwner: vi.fn(async () => undefined)
}
const args: Parameters<typeof registerIpcHandlers> = [
window as never,
{ capability: 'text' } as never,
'CommandOrControl+Shift+Space',
{} as never,
{} as never,
{ clear: vi.fn() } as never,
{} as never,
{
queueDueSchedules: vi.fn(() => []),
listConversationQueueItems: vi.fn(() => []),
listPendingConversationQueueIds: vi.fn(() => [])
} as never,
{ clear: vi.fn() } as never,
{} as never,
vi.fn(async () => {}),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
terminalSessionManager as never
]
const dispose = registerIpcHandlers(...args)
const event = {
sender: webContents,
senderFrame: webContents.mainFrame
}
await expect(
electronMocks.handlers.get(ipcChannels.terminalCreate)?.(event, {
target: { type: 'local' },
cols: 120,
rows: 36
})
).resolves.toEqual(snapshot)
expect(terminalSessionManager.create).toHaveBeenCalledWith(73, {
target: { type: 'local' },
cols: 120,
rows: 36
})
await vi.waitFor(() =>
expect(
terminalSessionManager.enableEventDelivery
).toHaveBeenCalledWith(73, snapshot.sessionId)
)
electronMocks.handlers.get(ipcChannels.terminalWrite)?.(event, {
sessionId: snapshot.sessionId,
data: 'echo ready\r'
})
electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, {
sessionId: snapshot.sessionId,
cols: 100,
rows: 28
})
electronMocks.handlers.get(ipcChannels.terminalAck)?.(event, {
sessionId: snapshot.sessionId,
sequence: 4
})
expect(
electronMocks.handlers.get(ipcChannels.terminalSnapshot)?.(
event,
{ sessionId: snapshot.sessionId }
)
).toEqual(snapshot)
await expect(
electronMocks.handlers.get(ipcChannels.terminalClose)?.(event, {
sessionId: snapshot.sessionId
})
).resolves.toMatchObject({ state: 'closed' })
expect(terminalSessionManager.write).toHaveBeenCalledWith(
73,
snapshot.sessionId,
'echo ready\r'
)
expect(terminalSessionManager.resize).toHaveBeenCalledWith(
73,
snapshot.sessionId,
{ cols: 100, rows: 28 }
)
expect(terminalSessionManager.acknowledge).toHaveBeenCalledWith(
73,
snapshot.sessionId,
4
)
expect(terminalSessionManager.snapshot).toHaveBeenCalledWith(
73,
snapshot.sessionId
)
expect(terminalSessionManager.close).toHaveBeenCalledWith(
73,
snapshot.sessionId
)
expect(() =>
electronMocks.handlers.get(ipcChannels.terminalWrite)?.(
{
sender: { ...webContents, id: 99 },
senderFrame: webContents.mainFrame
},
{
sessionId: snapshot.sessionId,
data: 'untrusted'
}
)
).toThrow('拒绝来自未知窗口的 IPC 请求')
expect(() =>
electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, {
sessionId: snapshot.sessionId,
cols: 0,
rows: 28
})
).toThrow()
await dispose()
expect(terminalSessionManager.closeOwner).toHaveBeenCalledWith(73)
})
})
type InvokeHandler = (event: unknown, input?: unknown) => unknown
type KnowledgeGrantMock = (
requestId: string,
+105 -1
View File
@@ -83,6 +83,14 @@ import {
knowledgeTaskItemSchema
} from '../shared/knowledge-task-contracts'
import { ipcChannels } from '../shared/ipc-channels'
import {
terminalAckRequestSchema,
terminalCloseRequestSchema,
terminalCreateRequestSchema,
terminalResizeRequestSchema,
terminalSnapshotRequestSchema,
terminalWriteRequestSchema
} from '../shared/terminal-contracts'
import {
browserProfileCreateInputSchema,
browserProfileRenameInputSchema,
@@ -119,6 +127,7 @@ import {
agentPackageInventorySchema
} from '../shared/agent-package-contracts'
import { assertTrustedSender } from './trusted-ipc-sender'
import type { TerminalSessionManager } from './terminal/terminal-session-manager'
import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts'
import {
speechModelActionInputSchema,
@@ -1088,7 +1097,8 @@ export function registerIpcHandlers(
remoteAgentConnectionManager?: Pick<
RemoteAgentConnectionManager,
'getHostConnectionState' | 'onHostConnectionStateChange'
>
>,
terminalSessionManager?: TerminalSessionManager
): () => Promise<void> {
type ActiveRequestLease = {
controller: AbortController
@@ -1097,6 +1107,12 @@ export function registerIpcHandlers(
release(): void
}
const activeRequests = new Map<string, ActiveRequestLease>()
const requireTerminalSessionManager = (): TerminalSessionManager => {
if (!terminalSessionManager) {
throw new Error('终端服务不可用')
}
return terminalSessionManager
}
const activeRequestConversations = new Map<
string,
ActiveRequestLease
@@ -1224,6 +1240,7 @@ export function registerIpcHandlers(
(channel) =>
channel !== ipcChannels.agentEvent &&
channel !== ipcChannels.browserState &&
channel !== ipcChannels.terminalEvent &&
channel !== ipcChannels.conversationNew &&
channel !== ipcChannels.settingsOpen &&
channel !== ipcChannels.versionCheckResult &&
@@ -3347,6 +3364,92 @@ export function registerIpcHandlers(
])
})
registerHandler(
ipcChannels.terminalCreate,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const snapshot = await manager.create(
event.sender.id,
terminalCreateRequestSchema.parse(input)
)
setImmediate(() => {
if (
!window.isDestroyed() &&
event.sender === window.webContents &&
!event.sender.isDestroyed()
) {
manager.enableEventDelivery(
event.sender.id,
snapshot.sessionId
)
}
})
return snapshot
}
)
registerHandler(ipcChannels.terminalWrite, (event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const request = terminalWriteRequestSchema.parse(input)
manager.write(
event.sender.id,
request.sessionId,
request.data
)
})
registerHandler(ipcChannels.terminalResize, (event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const request = terminalResizeRequestSchema.parse(input)
manager.resize(event.sender.id, request.sessionId, {
cols: request.cols,
rows: request.rows
})
})
registerHandler(
ipcChannels.terminalSnapshot,
(event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const request = terminalSnapshotRequestSchema.parse(input)
return manager.snapshot(
event.sender.id,
request.sessionId
)
}
)
registerHandler(
ipcChannels.terminalAck,
(event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const request = terminalAckRequestSchema.parse(input)
manager.acknowledge(
event.sender.id,
request.sessionId,
request.sequence
)
}
)
registerHandler(
ipcChannels.terminalClose,
async (event, input: unknown) => {
assertTrustedSender(event, window)
const manager = requireTerminalSessionManager()
const request = terminalCloseRequestSchema.parse(input)
return manager.close(
event.sender.id,
request.sessionId
)
}
)
registerHandler(
ipcChannels.browserInteract,
async (event, input: unknown) => {
@@ -7769,6 +7872,7 @@ export function registerIpcHandlers(
approvalBroker.clear()
goodbuddyConfigService?.clear()
pendingGoodBuddyConfigReload = false
await terminalSessionManager?.closeOwner(window.webContents.id)
await requestRendererPersistence()
await waitForRendererQuiescence()
for (const channel of channels) {
+27
View File
@@ -59,6 +59,8 @@ function createConnection(identity: SshPoolConnectionIdentity) {
return {
identity,
isUsable: vi.fn(() => true),
onDisconnect: vi.fn(() => () => undefined),
openTerminalShell: vi.fn(),
openAgentAttach: vi.fn(),
runAgentDoctor: vi.fn(),
runAgentBootstrapProbe: vi.fn(),
@@ -403,6 +405,13 @@ describe("authenticated ssh2 connection", () => {
callback(undefined, channel);
},
),
shell: vi.fn(
(
_window: unknown,
_options: unknown,
callback: (error: Error | undefined, stream: typeof channel) => void,
) => callback(undefined, channel),
),
sftp: vi.fn(),
});
const target = createTarget();
@@ -425,6 +434,7 @@ describe("authenticated ssh2 connection", () => {
);
await connection.openAgentAttach(verifyAgentInstallationId("agent-v1"));
await connection.openTerminalShell({ cols: 132, rows: 43 });
await expect(
connection.probeRemotePackageBootstrap({
operationId: "00000000-0000-4000-8000-000000000301",
@@ -470,6 +480,17 @@ describe("authenticated ssh2 connection", () => {
},
expect.any(Function),
);
expect(client.shell).toHaveBeenCalledWith(
{
term: "xterm-256color",
cols: 132,
rows: 43,
width: 0,
height: 0,
},
{ env: {}, x11: false },
expect.any(Function),
);
expect(connection).not.toHaveProperty("exec");
});
@@ -496,6 +517,7 @@ describe("authenticated ssh2 connection", () => {
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(),
shell: vi.fn(),
sftp: vi.fn(),
});
const target = createTarget();
@@ -530,6 +552,7 @@ describe("authenticated ssh2 connection", () => {
end: vi.fn(),
destroy: vi.fn(),
exec: vi.fn(),
shell: vi.fn(),
sftp: vi.fn(),
});
const controller = new AbortController();
@@ -569,6 +592,7 @@ describe("authenticated ssh2 connection", () => {
}, 0);
},
),
shell: vi.fn(),
sftp: vi.fn(),
});
const target = createTarget();
@@ -616,6 +640,7 @@ describe("authenticated ssh2 connection", () => {
}, 0);
},
),
shell: vi.fn(),
sftp: vi.fn(),
});
const target = createTarget();
@@ -673,6 +698,7 @@ describe("authenticated ssh2 connection", () => {
}, 0);
},
),
shell: vi.fn(),
sftp: vi.fn(),
});
const target = createTarget();
@@ -747,6 +773,7 @@ describe("authenticated ssh2 connection", () => {
}, 0);
},
),
shell: vi.fn(),
sftp: vi.fn(),
});
const target = createTarget();
+115 -4
View File
@@ -65,9 +65,20 @@ export type AgentDiagnosticResult = {
stderr: string;
};
export type SshTerminalShellOptions = {
cols: number;
rows: number;
term?: string;
};
export interface AuthenticatedSshConnection {
readonly identity: SshPoolConnectionIdentity;
isUsable(): boolean;
onDisconnect(listener: (error?: Error) => void): () => void;
openTerminalShell(
options: SshTerminalShellOptions,
signal?: AbortSignal,
): Promise<ClientChannel>;
openAgentAttach(
installationId: VerifiedAgentInstallationId,
signal?: AbortSignal,
@@ -128,19 +139,29 @@ export interface AuthenticatedSshConnection {
export interface SshConnectionLease extends Omit<
AuthenticatedSshConnection,
| "dispose"
| "onDisconnect"
| "openTerminalShell"
| "createRemotePackageUploadStaging"
| "prepareUploadedRemotePackageBootstrap"
> {
release(): void;
}
export interface SshTerminalConnectionLease extends SshConnectionLease {
onDisconnect(listener: (error?: Error) => void): () => void;
openTerminalShell(
options: SshTerminalShellOptions,
signal?: AbortSignal,
): Promise<ClientChannel>;
}
export interface SshRemotePackageBootstrapLease extends SshRemotePackageBootstrapExecutor {
readonly identity: SshPoolConnectionIdentity;
isUsable(): boolean;
release(): void;
}
type InternalSshConnectionLease = SshConnectionLease &
type InternalSshConnectionLease = SshTerminalConnectionLease &
Pick<
AuthenticatedSshConnection,
| "createRemotePackageUploadStaging"
@@ -149,7 +170,7 @@ type InternalSshConnectionLease = SshConnectionLease &
type ClientLike = Pick<
Client,
"connect" | "end" | "destroy" | "exec" | "sftp" | "on" | "once"
"connect" | "end" | "destroy" | "exec" | "shell" | "sftp" | "on" | "once"
>;
type SshConnectionDependencies = {
@@ -285,19 +306,26 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection {
private usable = true;
private disposed = false;
private agentBootstrapProbe: AgentBootstrapProbeResult | undefined;
private readonly disconnectListeners = new Set<(error?: Error) => void>();
private constructor(
readonly identity: SshPoolConnectionIdentity,
private readonly client: ClientLike,
private readonly controlPlanePackageInstaller: Buffer,
) {
const disconnected = (): void => {
const disconnected = (error?: Error): void => {
if (!this.usable) {
return;
}
this.usable = false;
this.agentBootstrapProbe = undefined;
for (const listener of [...this.disconnectListeners]) {
listener(error);
}
};
this.client.on("close", disconnected);
this.client.on("end", disconnected);
this.client.on("error", disconnected);
this.client.on("error", (error: Error) => disconnected(error));
}
static connect(
@@ -390,6 +418,70 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection {
return this.usable;
}
onDisconnect(listener: (error?: Error) => void): () => void {
if (!this.usable) {
queueMicrotask(() => listener());
return () => undefined;
}
this.disconnectListeners.add(listener);
return () => {
this.disconnectListeners.delete(listener);
};
}
openTerminalShell(
options: SshTerminalShellOptions,
signal?: AbortSignal,
): Promise<ClientChannel> {
this.assertUsable();
if (
!Number.isSafeInteger(options.cols) ||
options.cols <= 0 ||
!Number.isSafeInteger(options.rows) ||
options.rows <= 0
) {
return Promise.reject(new Error("SSH PTY 尺寸无效"));
}
if (signal?.aborted) {
return Promise.reject(createAbortError(signal));
}
return new Promise((resolve, reject) => {
let settled = false;
const finish = (error?: unknown, channel?: ClientChannel): void => {
if (settled) {
channel?.destroy();
return;
}
settled = true;
clearTimeout(timeout);
signal?.removeEventListener("abort", abort);
if (error || !channel) {
reject(error ?? new Error("无法打开 SSH 终端通道"));
} else {
resolve(channel);
}
};
const abort = (): void => {
finish(createAbortError(signal));
};
const timeout = setTimeout(() => {
finish(new Error("SSH 终端通道打开超时"));
}, CHANNEL_OPEN_TIMEOUT_MS);
signal?.addEventListener("abort", abort, { once: true });
this.client.shell(
{
term: options.term ?? "xterm-256color",
cols: options.cols,
rows: options.rows,
width: 0,
height: 0,
},
{ env: {}, x11: false },
(error, channel) => finish(error, channel),
);
});
}
async openAgentAttach(
installationId: VerifiedAgentInstallationId,
signal?: AbortSignal,
@@ -584,6 +676,10 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection {
this.disposed = true;
this.usable = false;
this.agentBootstrapProbe = undefined;
for (const listener of [...this.disconnectListeners]) {
listener();
}
this.disconnectListeners.clear();
this.client.end();
this.client.destroy();
}
@@ -881,6 +977,13 @@ export class SshConnectionPool {
};
}
async acquireTerminal(
target: SshConnectionPoolTarget,
signal?: AbortSignal,
): Promise<SshTerminalConnectionLease> {
return (await this.acquire(target, signal)) as SshTerminalConnectionLease;
}
disposeHost(hostId: string): void {
for (const entry of this.entries.values()) {
if (entry.identity.hostId === hostId) {
@@ -912,6 +1015,14 @@ export class SshConnectionPool {
const lease: InternalSshConnectionLease = {
identity: connection.identity,
isUsable: () => !released && connection.isUsable(),
onDisconnect: (listener) => {
assertActive();
return connection.onDisconnect(listener);
},
openTerminalShell: (options, signal) => {
assertActive();
return connection.openTerminalShell(options, signal);
},
openAgentAttach: (installationId, signal) => {
assertActive();
return connection.openAgentAttach(installationId, signal);
@@ -0,0 +1,318 @@
import type { IDisposable, IPty } from 'node-pty'
import { describe, expect, it, vi } from 'vitest'
import {
TERMINAL_LIMITS,
type TerminalEvent
} from '../../shared/terminal-contracts'
import {
LocalTerminalSession,
buildLocalTerminalEnvironment,
resolveLocalTerminalLaunch,
resolveLocalTerminalShell
} from './local-terminal-session'
class FakePty implements IPty {
readonly pid = 4321
readonly cols = 80
readonly rows = 24
readonly process = 'fake-shell'
handleFlowControl = false
readonly write = vi.fn()
readonly resize = vi.fn()
readonly clear = vi.fn()
readonly kill = vi.fn()
readonly pause = vi.fn()
readonly resume = vi.fn()
private dataListeners = new Set<(data: string) => void>()
private exitListeners = new Set<
(event: { exitCode: number; signal?: number }) => void
>()
readonly onData = (
listener: (data: string) => void
): IDisposable => {
this.dataListeners.add(listener)
return {
dispose: () => this.dataListeners.delete(listener)
}
}
readonly onExit = (
listener: (event: {
exitCode: number
signal?: number
}) => void
): IDisposable => {
this.exitListeners.add(listener)
return {
dispose: () => this.exitListeners.delete(listener)
}
}
emitData(data: string): void {
for (const listener of this.dataListeners) {
listener(data)
}
}
emitExit(exitCode: number, signal?: number): void {
for (const listener of [...this.exitListeners]) {
listener({ exitCode, signal })
}
}
}
const baseOptions = {
sessionId: '5d8f47a2-c681-4c26-95a2-2ed1a086fc78',
target: { type: 'local' } as const,
targetLabel: '本机',
title: '终端 · 本机 1',
size: { cols: 80, rows: 24 }
}
function sessionDependencies(
pty: FakePty,
overrides: Record<string, unknown> = {}
) {
return {
platform: 'linux' as const,
environment: {
SHELL: '/bin/bash',
PATH: '/bin',
SAFE_VALUE: 'visible'
},
executableExists: async (path: string) => path === '/bin/bash',
directoryExists: async () => true,
homeDirectory: () => '/home/tester',
spawn: vi.fn(() => pty),
terminate: vi.fn(async () => {
pty.emitExit(0)
}),
...overrides
}
}
describe('local terminal launch resolution', () => {
it('prefers pwsh, then PowerShell, then COMSPEC on Windows', async () => {
const available = new Set(['C:\\bin\\powershell.EXE'])
const shell = await resolveLocalTerminalShell({
platform: 'win32',
environment: {
Path: 'C:\\bin',
PATHEXT: '.EXE',
COMSPEC: 'C:\\Windows\\cmd.exe'
},
executableExists: async (path) => available.has(path)
})
expect(shell).toEqual({
shell: 'C:\\bin\\powershell.EXE',
shellLabel: 'powershell.EXE'
})
})
it('uses a valid POSIX SHELL and falls back to home for invalid cwd', async () => {
const launch = await resolveLocalTerminalLaunch('/missing', {
platform: 'linux',
environment: {
SHELL: '/usr/bin/fish',
PATH: '/usr/bin',
LANG: 'zh_CN.UTF-8',
GOODBUDDY_MODEL_API_KEY: 'secret',
ANTHROPIC_API_KEY: 'secret'
},
executableExists: async (path) => path === '/usr/bin/fish',
directoryExists: async (path) => path === '/home/tester',
homeDirectory: () => '/home/tester'
})
expect(launch.cwd).toBe('/home/tester')
expect(launch.shell).toBe('/usr/bin/fish')
expect(launch.env).toMatchObject({
SHELL: '/usr/bin/fish',
LANG: 'zh_CN.UTF-8'
})
expect(launch.env).not.toHaveProperty('GOODBUDDY_MODEL_API_KEY')
expect(launch.env).not.toHaveProperty('ANTHROPIC_API_KEY')
})
it('filters product and model credentials without dropping normal variables', () => {
expect(
buildLocalTerminalEnvironment({
PATH: '/bin',
CUSTOM_TOKEN: 'user-owned',
FACTORY_API_KEY: 'internal',
OPENAI_API_KEY: 'model'
})
).toEqual({
PATH: '/bin',
CUSTOM_TOKEN: 'user-owned'
})
})
})
describe('LocalTerminalSession', () => {
it('spawns the PTY and preserves ordered state, output, exit events', async () => {
const pty = new FakePty()
const dependencies = sessionDependencies(pty)
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies
})
const events: TerminalEvent[] = []
session.onEvent((event) => events.push(event))
pty.emitData('你好')
pty.emitExit(7, 15)
expect(dependencies.spawn).toHaveBeenCalledWith(
'/bin/bash',
[],
expect.objectContaining({
cols: 80,
rows: 24,
cwd: '/home/tester',
name: 'xterm-256color'
})
)
expect(events.map((event) => event.type)).toEqual([
'state',
'state',
'output',
'exit',
'state'
])
expect(events.map((event) => event.sequence)).toEqual([
1, 2, 3, 4, 5
])
expect(session.snapshot()).toMatchObject({
state: 'exited',
exit: { exitCode: 7, signal: '15' },
lastSequence: 5
})
})
it('forwards writes and deduplicates resize while running', async () => {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty)
})
expect(session.write('echo ok\r')).toBe(true)
expect(session.resize({ cols: 80, rows: 24 })).toBe(true)
expect(session.resize({ cols: 120, rows: 40 })).toBe(true)
expect(pty.write).toHaveBeenCalledWith('echo ok\r')
expect(pty.resize).toHaveBeenCalledTimes(1)
expect(pty.resize).toHaveBeenCalledWith(120, 40)
expect(session.snapshot().size).toEqual({
cols: 120,
rows: 40
})
})
it('splits output events at the UTF-8 boundary', async () => {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty)
})
const output: string[] = []
session.onEvent((event) => {
if (event.type === 'output') {
output.push(event.data)
}
})
const data =
'a'.repeat(TERMINAL_LIMITS.maximumEventBytes - 1) + '你'
pty.emitData(data)
expect(output.join('')).toBe(data)
expect(
output.every(
(chunk) =>
Buffer.byteLength(chunk) <=
TERMINAL_LIMITS.maximumEventBytes
)
).toBe(true)
})
it('pauses at the pending-event boundary and resumes after ACK', async () => {
const pty = new FakePty()
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: sessionDependencies(pty)
})
let lastSequence = 0
session.onEvent((event) => {
lastSequence = event.sequence
})
for (
let index = 0;
index < TERMINAL_LIMITS.maximumPendingEvents;
index += 1
) {
pty.emitData('x')
if (pty.pause.mock.calls.length > 0) {
break
}
}
expect(pty.pause).toHaveBeenCalledTimes(1)
session.acknowledge(lastSequence)
expect(pty.resume).toHaveBeenCalledTimes(1)
})
it('reports launch failures with stable failed state', async () => {
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies: {
platform: 'linux',
environment: { SHELL: '/missing' },
executableExists: async () => false,
directoryExists: async () => true,
homeDirectory: () => '/home/tester'
}
})
const events: TerminalEvent[] = []
session.onEvent((event) => events.push(event))
expect(session.snapshot()).toMatchObject({
state: 'failed',
error: { code: 'launch-failed', retryable: true }
})
expect(events.map((event) => event.type)).toEqual([
'state',
'error',
'state'
])
expect(session.write('ignored')).toBe(false)
})
it('closes once, rejects late operations, and keeps a stable exit', async () => {
const pty = new FakePty()
const dependencies = sessionDependencies(pty)
const session = await LocalTerminalSession.create({
...baseOptions,
dependencies
})
const firstClose = session.close()
const secondClose = session.close()
expect(secondClose).toBe(firstClose)
await firstClose
expect(dependencies.terminate).toHaveBeenCalledTimes(1)
expect(session.write('late')).toBe(false)
expect(session.resize({ cols: 90, rows: 30 })).toBe(false)
expect(session.snapshot()).toMatchObject({
state: 'exited',
exit: { exitCode: 0, signal: null }
})
await session.close()
expect(dependencies.terminate).toHaveBeenCalledTimes(1)
})
})
+672
View File
@@ -0,0 +1,672 @@
import { randomUUID } from 'node:crypto'
import { constants as fileSystemConstants } from 'node:fs'
import { access, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { posix, win32 } from 'node:path'
import * as nodePty from 'node-pty'
import type { IDisposable, IPty } from 'node-pty'
import {
TERMINAL_LIMITS,
type TerminalError,
type TerminalEvent,
type TerminalExit,
type TerminalSize,
type TerminalSnapshot,
type TerminalTarget
} from '../../shared/terminal-contracts'
import { requestProcessTreeTermination } from '../agent/child-process-termination'
import { runtimeProviderEnvironmentNames } from '../agent/process-environment'
export type LocalTerminalTarget = Extract<
TerminalTarget,
{ type: 'local' | 'project' }
>
export type LocalTerminalLaunch = {
cwd: string
shell: string
shellLabel: string
env: NodeJS.ProcessEnv
}
export type LocalPtySpawn = (
file: string,
args: string[],
options: nodePty.IPtyForkOptions
) => IPty
export type LocalTerminalDependencies = {
platform?: NodeJS.Platform
environment?: NodeJS.ProcessEnv
homeDirectory?: () => string
executableExists?: (path: string) => Promise<boolean>
directoryExists?: (path: string) => Promise<boolean>
spawn?: LocalPtySpawn
terminate?: (pty: IPty, platform: NodeJS.Platform) => Promise<void>
}
export type LocalTerminalSessionOptions = {
sessionId?: string
target: LocalTerminalTarget
targetLabel: string
title: string
size: TerminalSize
projectDirectory?: string
dependencies?: LocalTerminalDependencies
}
export type LocalTerminalEventListener = (event: TerminalEvent) => void
type UnsequencedTerminalEvent<T> = T extends TerminalEvent
? Omit<T, 'sessionId' | 'sequence'>
: never
type LocalTerminalEvent = UnsequencedTerminalEvent<TerminalEvent>
const CONTROL_EVENT_RESERVE = 2
const PAUSE_PENDING_EVENT_COUNT =
TERMINAL_LIMITS.maximumPendingEvents - CONTROL_EVENT_RESERVE
const RESUME_PENDING_EVENT_COUNT = Math.floor(
PAUSE_PENDING_EVENT_COUNT / 2
)
const CLOSE_WAIT_MS = 1_500
const FORCE_WAIT_MS = 250
const credentialEnvironmentNames = new Set([
...runtimeProviderEnvironmentNames,
'DEEPSEEK_API_KEY',
'DASHSCOPE_API_KEY',
'TOGETHER_API_KEY',
'FIREWORKS_API_KEY',
'PERPLEXITY_API_KEY',
'CEREBRAS_API_KEY',
'CLAUDE_CODE_OAUTH_TOKEN'
])
function isCredentialEnvironmentName(name: string): boolean {
const upperName = name.toUpperCase()
return (
upperName.startsWith('GOODBUDDY_') ||
upperName.startsWith('FACTORY_') ||
credentialEnvironmentNames.has(upperName)
)
}
/**
* Keep the user's normal interactive environment while removing credentials
* inherited by Electron Main for GoodBuddy and model-provider operation.
*/
export function buildLocalTerminalEnvironment(
source: NodeJS.ProcessEnv = process.env
): NodeJS.ProcessEnv {
const environment: NodeJS.ProcessEnv = {}
for (const [name, value] of Object.entries(source)) {
if (value !== undefined && !isCredentialEnvironmentName(name)) {
environment[name] = value
}
}
return environment
}
async function defaultExecutableExists(path: string): Promise<boolean> {
try {
await access(
path,
process.platform === 'win32'
? fileSystemConstants.F_OK
: fileSystemConstants.X_OK
)
return true
} catch {
return false
}
}
async function defaultDirectoryExists(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory()
} catch {
return false
}
}
async function findExecutableOnPath(
name: string,
environment: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
exists: (path: string) => Promise<boolean>
): Promise<string | undefined> {
const pathApi = platform === 'win32' ? win32 : posix
if (pathApi.isAbsolute(name)) {
return (await exists(name)) ? name : undefined
}
const pathValue =
environment.PATH ?? environment.Path ?? environment.path ?? ''
const extensions =
platform === 'win32'
? (environment.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
.split(';')
.filter(Boolean)
: ['']
const pathDelimiter = platform === 'win32' ? ';' : ':'
for (const directory of pathValue.split(pathDelimiter).filter(Boolean)) {
for (const extension of extensions) {
const candidate = pathApi.join(directory, `${name}${extension}`)
if (await exists(candidate)) {
return candidate
}
}
}
return undefined
}
export async function resolveLocalTerminalShell(
dependencies: LocalTerminalDependencies = {}
): Promise<{ shell: string; shellLabel: string }> {
const platform = dependencies.platform ?? process.platform
const environment = dependencies.environment ?? process.env
const exists = dependencies.executableExists ?? defaultExecutableExists
let shell: string | undefined
if (platform === 'win32') {
shell =
(await findExecutableOnPath(
'pwsh',
environment,
platform,
exists
)) ??
(await findExecutableOnPath(
'powershell',
environment,
platform,
exists
))
if (!shell) {
const comspec = environment.COMSPEC?.trim()
shell = comspec
? await findExecutableOnPath(
comspec,
environment,
platform,
exists
)
: undefined
}
} else {
const configuredShell = environment.SHELL?.trim()
if (
configuredShell &&
posix.isAbsolute(configuredShell) &&
(await exists(configuredShell))
) {
shell = configuredShell
} else {
const fallbacks =
platform === 'darwin'
? ['/bin/zsh', '/bin/bash', '/bin/sh']
: ['/bin/bash', '/bin/sh']
for (const candidate of fallbacks) {
if (await exists(candidate)) {
shell = candidate
break
}
}
}
}
if (!shell) {
throw new Error('No supported interactive shell is available')
}
return {
shell,
shellLabel:
platform === 'win32'
? win32.basename(shell)
: posix.basename(shell)
}
}
export async function resolveLocalTerminalWorkingDirectory(
projectDirectory: string | undefined,
dependencies: LocalTerminalDependencies = {}
): Promise<string> {
const isDirectory =
dependencies.directoryExists ?? defaultDirectoryExists
const home = (dependencies.homeDirectory ?? homedir)()
if (
projectDirectory?.trim() &&
(await isDirectory(projectDirectory))
) {
return projectDirectory
}
if (await isDirectory(home)) {
return home
}
throw new Error('No valid local terminal working directory is available')
}
export async function resolveLocalTerminalLaunch(
projectDirectory: string | undefined,
dependencies: LocalTerminalDependencies = {}
): Promise<LocalTerminalLaunch> {
const [shell, cwd] = await Promise.all([
resolveLocalTerminalShell(dependencies),
resolveLocalTerminalWorkingDirectory(
projectDirectory,
dependencies
)
])
return {
...shell,
cwd,
env: buildLocalTerminalEnvironment(
dependencies.environment ?? process.env
)
}
}
function splitBoundedOutput(data: string): string[] {
const encoded = Buffer.from(data)
const chunks: string[] = []
let offset = 0
while (offset < encoded.byteLength) {
let end = Math.min(
offset + TERMINAL_LIMITS.maximumEventBytes,
encoded.byteLength
)
if (end < encoded.byteLength) {
while (
end > offset &&
(encoded[end]! & 0xc0) === 0x80
) {
end -= 1
}
}
if (end === offset) {
end = Math.min(offset + 1, encoded.byteLength)
}
chunks.push(encoded.subarray(offset, end).toString('utf8'))
offset = end
}
return chunks
}
async function waitUntil(
predicate: () => boolean,
milliseconds: number
): Promise<void> {
if (predicate()) {
return
}
await new Promise<void>((resolve) => {
const finish = (): void => {
clearInterval(interval)
clearTimeout(timeout)
resolve()
}
const interval = setInterval(() => {
if (predicate()) {
finish()
}
}, 10)
const timeout = setTimeout(finish, milliseconds)
interval.unref?.()
timeout.unref?.()
})
}
async function defaultTerminate(
pty: IPty,
platform: NodeJS.Platform
): Promise<void> {
let exited = false
const exitSubscription = pty.onExit(() => {
exited = true
})
requestProcessTreeTermination(
{
pid: pty.pid,
killed: false,
exitCode: null,
kill: (signal) =>
platform === 'win32' ? pty.kill() : pty.kill(signal)
},
{
platform,
processGroup: platform !== 'win32',
waitMs: CLOSE_WAIT_MS
}
)
await waitUntil(() => exited, CLOSE_WAIT_MS)
if (!exited) {
requestProcessTreeTermination(
{
pid: pty.pid,
killed: false,
exitCode: null,
kill: () =>
platform === 'win32'
? pty.kill()
: pty.kill('SIGKILL')
},
{
platform,
processGroup: platform !== 'win32',
signal: 'SIGKILL',
waitMs: FORCE_WAIT_MS
}
)
await waitUntil(() => exited, FORCE_WAIT_MS)
}
exitSubscription.dispose()
}
export class LocalTerminalSession {
static async create(
options: LocalTerminalSessionOptions
): Promise<LocalTerminalSession> {
const session = new LocalTerminalSession(options)
await session.start()
return session
}
readonly sessionId: string
private readonly options: LocalTerminalSessionOptions
private readonly dependencies: LocalTerminalDependencies
private readonly listeners = new Set<LocalTerminalEventListener>()
private readonly pendingEvents: TerminalEvent[] = []
private readonly subscriptions: IDisposable[] = []
private pty: IPty | undefined
private state: TerminalSnapshot['state'] = 'starting'
private launch: LocalTerminalLaunch | undefined
private size: TerminalSize
private lastSequence = 0
private pendingOutputBytes = 0
private paused = false
private exit: TerminalExit | null = null
private error: TerminalError | null = null
private closePromise: Promise<TerminalSnapshot> | undefined
private constructor(options: LocalTerminalSessionOptions) {
this.options = options
this.dependencies = options.dependencies ?? {}
this.sessionId = options.sessionId ?? randomUUID()
this.size = { ...options.size }
this.emit({ type: 'state', state: 'starting' })
}
private async start(): Promise<void> {
try {
this.launch = await resolveLocalTerminalLaunch(
this.options.target.type === 'project'
? this.options.projectDirectory
: undefined,
this.dependencies
)
const spawn = this.dependencies.spawn ?? nodePty.spawn
this.pty = spawn(this.launch.shell, [], {
name: 'xterm-256color',
cols: this.size.cols,
rows: this.size.rows,
cwd: this.launch.cwd,
env: this.launch.env
})
this.subscriptions.push(
this.pty.onData((data) => this.handleOutput(data)),
this.pty.onExit((result) =>
this.handleExit(result.exitCode, result.signal)
)
)
this.state = 'running'
this.emit({ type: 'state', state: 'running' })
} catch (cause) {
this.fail(
'launch-failed',
cause instanceof Error
? cause.message
: 'Failed to launch the local terminal'
)
}
}
snapshot(): TerminalSnapshot {
const launch = this.launch
return {
sessionId: this.sessionId,
target: this.options.target,
targetLabel: this.options.targetLabel,
title: this.options.title,
state: this.state,
shell: launch?.shellLabel ?? '未知',
workingDirectory:
launch?.cwd ??
this.options.projectDirectory ??
(this.dependencies.homeDirectory ?? homedir)(),
size: { ...this.size },
lastSequence: this.lastSequence,
exit: this.exit,
error: this.error
}
}
onEvent(listener: LocalTerminalEventListener): () => void {
this.listeners.add(listener)
for (const event of this.pendingEvents) {
listener(event)
}
return () => {
this.listeners.delete(listener)
}
}
write(data: string): boolean {
if (this.state !== 'running' || !this.pty) {
return false
}
this.pty.write(data)
return true
}
resize(size: TerminalSize): boolean {
if (this.state !== 'running' || !this.pty) {
return false
}
if (
this.size.cols === size.cols &&
this.size.rows === size.rows
) {
return true
}
this.pty.resize(size.cols, size.rows)
this.size = { ...size }
return true
}
acknowledge(sequence: number): void {
while (
this.pendingEvents[0] &&
this.pendingEvents[0].sequence <= sequence
) {
const event = this.pendingEvents.shift()
if (event?.type === 'output') {
this.pendingOutputBytes -= Buffer.byteLength(event.data)
}
}
if (
this.paused &&
this.pendingEvents.length <= RESUME_PENDING_EVENT_COUNT &&
this.pendingOutputBytes <=
TERMINAL_LIMITS.maximumBufferedOutputBytes / 2
) {
this.paused = false
this.pty?.resume()
}
}
close(): Promise<TerminalSnapshot> {
if (this.closePromise) {
return this.closePromise
}
this.closePromise = this.closeInternal()
return this.closePromise
}
private async closeInternal(): Promise<TerminalSnapshot> {
if (
this.state === 'exited' ||
this.state === 'interrupted' ||
(this.state === 'failed' && !this.pty)
) {
return this.snapshot()
}
const preserveFailedState = this.state === 'failed'
if (!preserveFailedState) {
this.state = 'closing'
this.emit({ type: 'state', state: 'closing' })
}
const pty = this.pty
if (pty) {
try {
await (
this.dependencies.terminate ?? defaultTerminate
)(pty, this.dependencies.platform ?? process.platform)
} catch (cause) {
this.error = {
code: 'internal-error',
message: this.boundErrorMessage(cause),
retryable: false
}
this.emit({ type: 'error', error: this.error })
} finally {
if (preserveFailedState) {
this.disposeSubscriptions()
}
}
}
if (this.state === 'closing') {
this.handleExit(null, 'SIGTERM')
}
return this.snapshot()
}
private handleOutput(data: string): void {
if (this.state !== 'running' || data.length === 0) {
return
}
for (const chunk of splitBoundedOutput(data)) {
if (
this.pendingEvents.length >= PAUSE_PENDING_EVENT_COUNT ||
this.pendingOutputBytes + Buffer.byteLength(chunk) >
TERMINAL_LIMITS.maximumBufferedOutputBytes
) {
this.fail(
'output-limit-exceeded',
'Terminal output exceeded the bounded Main process buffer'
)
void this.close()
return
}
this.pendingOutputBytes += Buffer.byteLength(chunk)
this.emit({ type: 'output', data: chunk })
this.updateBackpressure()
}
}
private updateBackpressure(): void {
if (
!this.paused &&
(this.pendingEvents.length >= PAUSE_PENDING_EVENT_COUNT ||
this.pendingOutputBytes >=
TERMINAL_LIMITS.maximumBufferedOutputBytes)
) {
this.paused = true
this.pty?.pause()
}
}
private handleExit(
exitCode: number | null,
signal?: number | string
): void {
if (this.state === 'exited' || this.state === 'failed') {
return
}
this.disposeSubscriptions()
this.exit = {
exitCode:
typeof exitCode === 'number' &&
Number.isSafeInteger(exitCode)
? exitCode
: null,
signal:
signal === undefined || signal === null
? null
: String(signal)
}
if (this.exit.exitCode === null && this.exit.signal === null) {
this.exit.signal = 'unknown'
}
this.state = 'exited'
this.emit({ type: 'exit', exit: this.exit })
this.emit({ type: 'state', state: 'exited' })
}
private fail(
code: TerminalError['code'],
message: string
): void {
if (this.state === 'failed' || this.state === 'exited') {
return
}
this.error = {
code,
message: Buffer.from(message)
.subarray(0, TERMINAL_LIMITS.maximumErrorMessageBytes)
.toString('utf8') || 'Local terminal failed',
retryable: code === 'launch-failed'
}
this.state = 'failed'
this.emit({ type: 'error', error: this.error })
this.emit({ type: 'state', state: 'failed' })
}
private emit(
event: LocalTerminalEvent
): void {
if (this.lastSequence >= Number.MAX_SAFE_INTEGER) {
this.state = 'failed'
this.error = {
code: 'internal-error',
message: 'Terminal event sequence was exhausted',
retryable: false
}
return
}
const sequenced = {
...event,
sessionId: this.sessionId,
sequence: ++this.lastSequence
} as TerminalEvent
this.pendingEvents.push(sequenced)
for (const listener of this.listeners) {
listener(sequenced)
}
}
private boundErrorMessage(cause: unknown): string {
const message =
cause instanceof Error
? cause.message
: 'Failed to close the local terminal'
return (
Buffer.from(message)
.subarray(0, TERMINAL_LIMITS.maximumErrorMessageBytes)
.toString('utf8') || 'Failed to close the local terminal'
)
}
private disposeSubscriptions(): void {
for (const subscription of this.subscriptions.splice(0)) {
subscription.dispose()
}
}
}
@@ -0,0 +1,271 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import type {
AuthenticatedSshConnection,
SshConnectionPoolTarget,
SshPoolConnectionIdentity,
SshTerminalShellOptions,
} from "../ssh/ssh-connection-pool";
import { SshConnectionPool } from "../ssh/ssh-connection-pool";
import {
createSshTerminalSession,
sshTerminalInternals,
} from "./ssh-terminal-session";
const sessionId = "00000000-0000-4000-8000-000000000501";
const hostId = "00000000-0000-4000-8000-000000000502";
const projectId = "00000000-0000-4000-8000-000000000503";
function createTarget(): SshConnectionPoolTarget {
return {
host: {
id: hostId,
name: "Build host",
hostname: "build.example.com",
port: 22,
username: "builder",
authentication: "password",
password: "private password",
hostKey: {
algorithm: "ssh-ed25519",
publicKeyBase64: "pinned",
fingerprintSha256: `SHA256:${"A".repeat(43)}`,
generation: 3,
},
},
hostRevision: 7,
hostKeyGeneration: 3,
};
}
function createChannel() {
const emitter = new EventEmitter();
return Object.assign(emitter, {
write: vi.fn(() => true),
setWindow: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
close: vi.fn(),
destroy: vi.fn(),
});
}
function createHarness(idleTimeoutMs = 60_000) {
const channel = createChannel();
let usable = true;
const disconnectListeners = new Set<(error?: Error) => void>();
let shellOptions: SshTerminalShellOptions | undefined;
const identity: SshPoolConnectionIdentity = {
hostId,
hostRevision: 7,
hostKeyGeneration: 3,
authenticationIdentity: "a".repeat(64),
};
const connection = {
identity,
isUsable: vi.fn(() => usable),
onDisconnect: vi.fn((listener: (error?: Error) => void) => {
disconnectListeners.add(listener);
return () => disconnectListeners.delete(listener);
}),
openTerminalShell: vi.fn(
async (options: SshTerminalShellOptions) => {
shellOptions = options;
return channel as never;
},
),
openAgentAttach: vi.fn(),
runAgentDoctor: vi.fn(),
runAgentBootstrapProbe: vi.fn(),
probeRemotePackageBootstrap: vi.fn(),
createRemotePackageUploadStaging: vi.fn(),
prepareRemotePackageBootstrap: vi.fn(),
prepareUploadedRemotePackageBootstrap: vi.fn(),
commitRemotePackageBootstrap: vi.fn(),
cleanupRemotePackageBootstrap: vi.fn(),
runAgentLifecycleAction: vi.fn(),
runAgentRuntimeAction: vi.fn(),
openStagedSftp: vi.fn(),
listDirectories: vi.fn(),
dispose: vi.fn(() => {
usable = false;
}),
} satisfies AuthenticatedSshConnection;
const pool = new SshConnectionPool(async () => connection, {
idleTimeoutMs,
});
const events: Array<{
type: string;
sequence: number;
[key: string]: unknown;
}> = [];
return {
channel,
connection,
pool,
events,
shellOptions: () => shellOptions,
disconnect(error?: Error) {
usable = false;
for (const listener of [...disconnectListeners]) {
listener(error);
}
},
};
}
async function openSession(harness: ReturnType<typeof createHarness>) {
return createSshTerminalSession(harness.pool, {
sessionId,
target: { type: "project", projectId },
targetLabel: "Build host",
title: "终端 · Build host",
shell: "/bin/bash",
workingDirectory: "/srv/team's app",
size: { cols: 120, rows: 40 },
poolTarget: createTarget(),
onEvent: (event) => harness.events.push(event),
});
}
describe("SSH terminal session", () => {
it("holds a pool lease and opens a login shell with the requested PTY and quoted cwd", async () => {
vi.useFakeTimers();
try {
const harness = createHarness(100);
const session = await openSession(harness);
expect(harness.shellOptions()).toEqual({
cols: 120,
rows: 40,
term: "xterm-256color",
});
expect(harness.channel.write).toHaveBeenCalledWith(
"cd -- '/srv/team'\"'\"'s app' || exit\r",
);
await vi.advanceTimersByTimeAsync(1_000);
expect(harness.connection.dispose).not.toHaveBeenCalled();
expect(session.close()).toMatchObject({
state: "exited",
exit: { exitCode: null, signal: "SIGHUP" },
});
expect(session.close()).toMatchObject({ state: "exited" });
expect(harness.channel.close).toHaveBeenCalledOnce();
expect(harness.channel.destroy).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(100);
expect(harness.connection.dispose).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("forwards writes and resizes while running and returns an authoritative snapshot", async () => {
const harness = createHarness();
const session = await openSession(harness);
session.write("printf '你好'\\r");
session.resize({ cols: 90, rows: 28 });
expect(harness.channel.write).toHaveBeenLastCalledWith(
"printf '你好'\\r",
);
expect(harness.channel.setWindow).toHaveBeenCalledWith(28, 90, 0, 0);
expect(session.snapshot()).toMatchObject({
sessionId,
state: "running",
shell: "/bin/bash",
workingDirectory: "/srv/team's app",
size: { cols: 90, rows: 28 },
lastSequence: 1,
});
session.close();
});
it("emits ordered bounded output and pauses until acknowledged", async () => {
const harness = createHarness();
const session = await openSession(harness);
for (let index = 0; index < 255; index += 1) {
harness.channel.emit("data", `chunk-${index}`);
}
expect(harness.events).toHaveLength(256);
expect(harness.events.map((event) => event.sequence)).toEqual(
Array.from({ length: 256 }, (_, index) => index + 1),
);
expect(harness.channel.pause).toHaveBeenCalledOnce();
session.acknowledge(256);
expect(harness.channel.resume).toHaveBeenCalledOnce();
session.close();
});
it("marks a disconnected SSH transport interrupted immediately and rejects late I/O", async () => {
const harness = createHarness();
const session = await openSession(harness);
harness.disconnect(new Error("socket reset"));
expect(session.snapshot()).toMatchObject({
state: "interrupted",
error: {
code: "interrupted",
message: "socket reset",
retryable: true,
},
});
expect(harness.events.at(-1)).toMatchObject({
type: "state",
state: "interrupted",
});
const writes = harness.channel.write.mock.calls.length;
session.write("late");
session.resize({ cols: 80, rows: 24 });
expect(harness.channel.write).toHaveBeenCalledTimes(writes);
expect(harness.channel.setWindow).not.toHaveBeenCalled();
session.close();
});
it("records normal shell exit before releasing the channel lease", async () => {
const harness = createHarness();
const session = await openSession(harness);
harness.channel.emit("exit", 7);
harness.channel.emit("close");
expect(session.snapshot()).toMatchObject({
state: "exited",
exit: { exitCode: 7, signal: null },
});
expect(harness.events.slice(-2)).toMatchObject([
{ type: "exit", exit: { exitCode: 7, signal: null } },
{ type: "state", state: "exited" },
]);
session.close();
});
it("strictly quotes POSIX paths and rejects line-based command injection", () => {
expect(sshTerminalInternals.quotePosixShellArgument("a'b")).toBe(
"'a'\"'\"'b'",
);
expect(() =>
sshTerminalInternals.quotePosixShellArgument("/srv/app\nwhoami"),
).toThrow("无效字符");
});
it("opens a Host-home shell without injecting a cwd command", async () => {
const harness = createHarness();
const session = await createSshTerminalSession(harness.pool, {
sessionId,
target: { type: "project", projectId },
targetLabel: "Build host",
title: "终端 · Build host",
size: { cols: 80, rows: 24 },
poolTarget: createTarget(),
onEvent: (event) => harness.events.push(event),
});
expect(harness.channel.write).not.toHaveBeenCalled();
expect(session.snapshot().workingDirectory).toBe("~");
session.close();
});
});
+316
View File
@@ -0,0 +1,316 @@
import { StringDecoder } from "node:string_decoder";
import type { ClientChannel } from "ssh2";
import {
TERMINAL_LIMITS,
terminalSizeSchema,
type TerminalError,
type TerminalEvent,
type TerminalExit,
type TerminalSessionId,
type TerminalSize,
type TerminalSnapshot,
type TerminalTarget,
} from "../../shared/terminal-contracts";
import {
type SshConnectionPool,
type SshConnectionPoolTarget,
type SshTerminalConnectionLease,
} from "../ssh/ssh-connection-pool";
const SSH_TERMINAL_TERM = "xterm-256color";
export type SshTerminalSessionOptions = {
sessionId: TerminalSessionId;
target: TerminalTarget;
targetLabel: string;
title: string;
shell?: string;
workingDirectory?: string;
size: TerminalSize;
poolTarget: SshConnectionPoolTarget;
onEvent: (event: TerminalEvent) => void;
signal?: AbortSignal;
};
export type SshTerminalSession = {
write(data: string): void;
resize(size: TerminalSize): void;
acknowledge(sequence: number): void;
snapshot(): TerminalSnapshot;
close(): TerminalSnapshot;
};
type RunningSession = {
lease: SshTerminalConnectionLease;
channel: ClientChannel;
removeDisconnectListener: () => void;
};
type TerminalEventPayload =
| { type: "output"; data: string }
| { type: "state"; state: TerminalSnapshot["state"] }
| { type: "exit"; exit: TerminalExit }
| { type: "error"; error: TerminalError };
function quotePosixShellArgument(value: string): string {
if (value.includes("\0") || value.includes("\r") || value.includes("\n")) {
throw new Error("远程工作目录包含无效字符");
}
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
function eventByteLength(event: TerminalEvent): number {
switch (event.type) {
case "output":
return Buffer.byteLength(event.data);
case "error":
return Buffer.byteLength(event.error.message) + 64;
default:
return 64;
}
}
function splitUtf8(text: string, maximumBytes: number): string[] {
const chunks: string[] = [];
let chunk = "";
let chunkBytes = 0;
for (const character of text) {
const characterBytes = Buffer.byteLength(character);
if (chunk && chunkBytes + characterBytes > maximumBytes) {
chunks.push(chunk);
chunk = "";
chunkBytes = 0;
}
chunk += character;
chunkBytes += characterBytes;
}
if (chunk) {
chunks.push(chunk);
}
return chunks;
}
export async function createSshTerminalSession(
pool: SshConnectionPool,
options: SshTerminalSessionOptions,
): Promise<SshTerminalSession> {
const initialSize = terminalSizeSchema.parse(options.size);
const workingDirectoryCommand = options.workingDirectory
? `cd -- ${quotePosixShellArgument(options.workingDirectory)} || exit\r`
: undefined;
const lease = await pool.acquireTerminal(options.poolTarget, options.signal);
let channel: ClientChannel;
try {
channel = await lease.openTerminalShell(
{
cols: initialSize.cols,
rows: initialSize.rows,
term: SSH_TERMINAL_TERM,
},
options.signal,
);
} catch (error) {
lease.release();
throw error;
}
let state: TerminalSnapshot["state"] = "running";
let size = initialSize;
let sequence = 0;
let exit: TerminalExit | null = null;
let error: TerminalError | null = null;
let closed = false;
let paused = false;
let pendingBytes = 0;
const pendingEvents: Array<{ event: TerminalEvent; bytes: number }> = [];
const decoder = new StringDecoder("utf8");
let running: RunningSession | undefined;
const emit = (event: TerminalEventPayload): void => {
sequence += 1;
const sequenced = {
...event,
sessionId: options.sessionId,
sequence,
} as TerminalEvent;
const bytes = eventByteLength(sequenced);
pendingEvents.push({ event: sequenced, bytes });
pendingBytes += bytes;
options.onEvent(sequenced);
if (
!paused &&
(pendingEvents.length >= TERMINAL_LIMITS.maximumPendingEvents ||
pendingBytes >=
TERMINAL_LIMITS.maximumBufferedOutputBytes -
TERMINAL_LIMITS.maximumEventBytes)
) {
paused = true;
channel.pause();
}
};
const release = (): void => {
const active = running;
running = undefined;
active?.removeDisconnectListener();
active?.lease.release();
};
const interrupt = (disconnectError?: Error): void => {
if (closed || state !== "running") {
return;
}
state = "interrupted";
error = {
code: "interrupted",
message: disconnectError?.message || "SSH 连接已中断",
retryable: true,
};
emit({ type: "error", error });
emit({ type: "state", state });
release();
channel.destroy();
};
const removeDisconnectListener = lease.onDisconnect(interrupt);
running = { lease, channel, removeDisconnectListener };
channel.on("data", (chunk: Buffer | string) => {
if (closed || state !== "running") {
return;
}
const text =
typeof chunk === "string" ? chunk : decoder.write(Buffer.from(chunk));
for (const data of splitUtf8(
text,
TERMINAL_LIMITS.maximumEventBytes,
)) {
emit({ type: "output", data });
}
});
channel.once(
"exit",
(code: number | null, signal?: string) => {
if (closed || state !== "running") {
return;
}
exit =
typeof code === "number"
? { exitCode: code, signal: null }
: { exitCode: null, signal: signal || "UNKNOWN" };
},
);
channel.once("error", (channelError: Error) => {
interrupt(channelError);
});
channel.once("close", () => {
if (closed || state !== "running") {
release();
return;
}
const finalText = decoder.end();
if (finalText) {
for (const data of splitUtf8(
finalText,
TERMINAL_LIMITS.maximumEventBytes,
)) {
emit({ type: "output", data });
}
}
if (!lease.isUsable()) {
interrupt();
return;
}
state = "exited";
exit ??= { exitCode: 0, signal: null };
emit({ type: "exit", exit });
emit({ type: "state", state });
release();
});
if (workingDirectoryCommand) {
channel.write(workingDirectoryCommand);
}
emit({ type: "state", state });
const currentSnapshot = (): TerminalSnapshot => ({
sessionId: options.sessionId,
target: options.target,
targetLabel: options.targetLabel,
title: options.title,
state,
shell: options.shell ?? "login shell",
workingDirectory: options.workingDirectory || "~",
size,
lastSequence: sequence,
exit,
error,
});
return {
write(data) {
if (closed || state !== "running") {
return;
}
channel.write(data);
},
resize(nextSize) {
if (closed || state !== "running") {
return;
}
size = terminalSizeSchema.parse(nextSize);
channel.setWindow(size.rows, size.cols, 0, 0);
},
acknowledge(acknowledgedSequence) {
if (
!Number.isSafeInteger(acknowledgedSequence) ||
acknowledgedSequence < 0
) {
throw new Error("终端输出确认序号无效");
}
while (
pendingEvents[0] &&
pendingEvents[0].event.sequence <= acknowledgedSequence
) {
pendingBytes -= pendingEvents.shift()?.bytes ?? 0;
}
if (
paused &&
pendingEvents.length < TERMINAL_LIMITS.maximumPendingEvents &&
pendingBytes <
TERMINAL_LIMITS.maximumBufferedOutputBytes -
TERMINAL_LIMITS.maximumEventBytes
) {
paused = false;
channel.resume();
}
},
snapshot() {
return currentSnapshot();
},
close() {
if (closed) {
return currentSnapshot();
}
closed = true;
if (state === "running") {
state = "closing";
emit({ type: "state", state });
exit = { exitCode: null, signal: "SIGHUP" };
}
release();
channel.close();
channel.destroy();
if (state === "closing") {
state = "exited";
emit({ type: "exit", exit: exit! });
emit({ type: "state", state });
}
return currentSnapshot();
},
};
}
export const sshTerminalInternals = {
quotePosixShellArgument,
};
@@ -0,0 +1,353 @@
import { describe, expect, it, vi } from 'vitest'
import {
TERMINAL_LIMITS,
type TerminalEvent,
type TerminalSnapshot,
type TerminalTarget
} from '../../shared/terminal-contracts'
import {
TerminalSessionManager,
TerminalSessionManagerError,
type ManagedTerminalSession,
type TerminalSessionManagerDependencies
} from './terminal-session-manager'
const projectId = '00000000-0000-4000-8000-000000000601'
const hostId = '00000000-0000-4000-8000-000000000602'
function snapshot(
sessionId: string,
target: TerminalTarget,
workingDirectory = '/home/tester'
): TerminalSnapshot {
return {
sessionId,
target,
targetLabel: target.type === 'local' ? '本机' : 'Build host',
title: '终端',
state: 'running',
shell: 'bash',
workingDirectory,
size: { cols: 80, rows: 24 },
lastSequence: 1,
exit: null,
error: null
}
}
function fakeSession(
value: TerminalSnapshot,
initialEvents: TerminalEvent[] = []
): ManagedTerminalSession & {
emit(event: TerminalEvent): void
close: ReturnType<typeof vi.fn>
write: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
} {
let current = value
const listeners = new Set<(event: TerminalEvent) => void>()
return {
snapshot: () => current,
write: vi.fn(() => true),
resize: vi.fn(() => true),
acknowledge: vi.fn(),
close: vi.fn(async () => {
current = { ...current, state: 'closing' }
return current
}),
onEvent(listener) {
listeners.add(listener)
initialEvents.forEach(listener)
return () => listeners.delete(listener)
},
emit(event) {
for (const listener of listeners) {
listener(event)
}
}
}
}
function harness(
overrides: Partial<TerminalSessionManagerDependencies> = {}
) {
let nextId = 700
const delivered: Array<{ ownerId: number; event: TerminalEvent }> = []
const localSessions: ReturnType<typeof fakeSession>[] = []
const sshSessions: ReturnType<typeof fakeSession>[] = []
const createLocalSession = vi.fn(async (options) => {
const event = {
sessionId: options.sessionId!,
sequence: 1,
type: 'output' as const,
data: 'prompt'
}
const session = fakeSession(
snapshot(
options.sessionId!,
options.target,
options.projectDirectory ?? '/home/tester'
),
[event]
)
localSessions.push(session)
return session
})
const createSshSession = vi.fn(async (_pool, options) => {
options.onEvent({
sessionId: options.sessionId,
sequence: 1,
type: 'output',
data: 'remote prompt'
})
const session = fakeSession(
snapshot(
options.sessionId,
options.target,
options.workingDirectory
)
)
sshSessions.push(session)
return session
})
const dependencies = {
database: {
getProject: vi.fn(() => ({
id: projectId,
name: 'Workspace',
rootPath: 'legacy',
executionSpace: { kind: 'local', rootPath: '/authoritative/root' }
}))
},
executionSpaceResolver: {
resolveProject: vi.fn((project: {
executionSpace: { kind: 'local'; rootPath: string }
}) => ({
kind: 'local' as const,
rootPath: project.executionSpace.rootPath,
cacheIdentity: 'local',
routeIdentity: 'local',
workspaceAccess: {}
}))
},
targetResolver: {
resolve: vi.fn(async () => ({
host: { id: hostId, name: 'Build host' },
hostRevision: 2,
hostKeyGeneration: 3
}))
},
sshPool: {} as never,
remoteEnabled: vi.fn(() => true),
deliverEvent: vi.fn((ownerId, event) => {
delivered.push({ ownerId, event })
}),
createLocalSession,
createSshSession,
createSessionId: () =>
`00000000-0000-4000-8000-${String(nextId++).padStart(12, '0')}`,
...overrides
} as unknown as TerminalSessionManagerDependencies
return {
manager: new TerminalSessionManager(dependencies),
dependencies,
delivered,
localSessions,
sshSessions,
createLocalSession,
createSshSession
}
}
describe('TerminalSessionManager routing and delivery', () => {
it('routes Home local and authoritative local project roots', async () => {
const test = harness()
const local = await test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})
const project = await test.manager.create(1, {
target: { type: 'project', projectId },
cols: 90,
rows: 30
})
expect(local.workingDirectory).toBe('/home/tester')
expect(project.workingDirectory).toBe('/authoritative/root')
expect(test.createLocalSession.mock.calls[0]?.[0].projectDirectory)
.toBeUndefined()
expect(test.createLocalSession.mock.calls[1]?.[0].projectDirectory)
.toBe('/authoritative/root')
})
it('routes SSH projects to their authoritative remote root', async () => {
const test = harness()
vi.mocked(test.dependencies.executionSpaceResolver.resolveProject)
.mockReturnValue({
kind: 'ssh',
hostId,
remoteRootPath: '/srv/project',
cacheIdentity: 'ssh',
routeIdentity: 'ssh',
workspaceAccess: {} as never
})
const remote = await test.manager.create(1, {
target: { type: 'project', projectId },
cols: 80,
rows: 24
})
expect(test.createSshSession.mock.calls[0]?.[1].workingDirectory)
.toBe('/srv/project')
expect(remote.workingDirectory).toBe('/srv/project')
expect(test.dependencies.targetResolver.resolve).toHaveBeenCalledWith(hostId)
})
it('gates all remote targets', async () => {
const test = harness({ remoteEnabled: () => false })
vi.mocked(test.dependencies.executionSpaceResolver.resolveProject)
.mockReturnValue({
kind: 'ssh',
hostId,
remoteRootPath: '/srv/project',
cacheIdentity: 'ssh',
routeIdentity: 'ssh',
workspaceAccess: {} as never
})
await expect(test.manager.create(1, {
target: { type: 'project', projectId },
cols: 80,
rows: 24
})).rejects.toMatchObject({ code: 'target-unavailable' })
expect(test.createSshSession).not.toHaveBeenCalled()
})
it('delays startup events until delivery is explicitly enabled', async () => {
const test = harness()
const created = await test.manager.create(7, {
target: { type: 'local' },
cols: 80,
rows: 24
})
expect(test.delivered).toEqual([])
test.manager.enableEventDelivery(7, created.sessionId)
expect(test.delivered).toEqual([
{
ownerId: 7,
event: expect.objectContaining({ data: 'prompt', sequence: 1 })
}
])
})
})
describe('TerminalSessionManager ownership and lifecycle', () => {
it('revalidates ownership for every session operation', async () => {
const test = harness()
const created = await test.manager.create(11, {
target: { type: 'local' },
cols: 80,
rows: 24
})
const calls = [
() => test.manager.write(12, created.sessionId, 'x'),
() => test.manager.resize(12, created.sessionId, { cols: 90, rows: 30 }),
() => test.manager.snapshot(12, created.sessionId),
() => test.manager.acknowledge(12, created.sessionId, 1),
() => test.manager.close(12, created.sessionId),
() => test.manager.enableEventDelivery(12, created.sessionId)
]
for (const call of calls) {
expect(call).toThrowError(
expect.objectContaining({ code: 'session-not-found' })
)
}
expect(test.localSessions[0]?.write).not.toHaveBeenCalled()
})
it('enforces the per-window limit without affecting another owner', async () => {
const test = harness()
for (let index = 0;
index < TERMINAL_LIMITS.maximumSessionsPerWindow;
index += 1) {
await test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})
}
await expect(test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})).rejects.toBeInstanceOf(TerminalSessionManagerError)
await expect(test.manager.create(2, {
target: { type: 'local' },
cols: 80,
rows: 24
})).resolves.toBeDefined()
})
it('releases closed records and ignores late delivery ACKs', async () => {
const test = harness()
const created = await test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})
const first = await test.manager.close(1, created.sessionId)
expect(first.state).toBe('closing')
expect(test.localSessions[0]?.close).toHaveBeenCalledTimes(1)
expect(() =>
test.manager.acknowledge(1, created.sessionId, 1)
).not.toThrow()
expect(() => test.manager.close(1, created.sessionId))
.toThrowError(expect.objectContaining({ code: 'session-not-found' }))
expect(() => test.manager.write(1, created.sessionId, 'late'))
.toThrowError(expect.objectContaining({ code: 'session-not-found' }))
expect(() => test.manager.resize(
1,
created.sessionId,
{ cols: 100, rows: 40 }
)).toThrowError(expect.objectContaining({
code: 'session-not-found'
}))
await expect(test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})).resolves.toBeDefined()
})
it('closes only one owner and disposes all sessions idempotently', async () => {
const test = harness()
const first = await test.manager.create(1, {
target: { type: 'local' },
cols: 80,
rows: 24
})
const second = await test.manager.create(2, {
target: { type: 'local' },
cols: 80,
rows: 24
})
await test.manager.closeOwner(1)
expect(() => test.manager.snapshot(1, first.sessionId))
.toThrowError(expect.objectContaining({ code: 'session-not-found' }))
expect(test.manager.snapshot(2, second.sessionId).state).toBe('running')
await Promise.all([test.manager.dispose(), test.manager.dispose()])
expect(test.localSessions[0]?.close).toHaveBeenCalledTimes(1)
expect(test.localSessions[1]?.close).toHaveBeenCalledTimes(1)
await expect(test.manager.create(2, {
target: { type: 'local' },
cols: 80,
rows: 24
})).rejects.toMatchObject({ code: 'internal-error' })
})
})
@@ -0,0 +1,479 @@
import { randomUUID } from 'node:crypto'
import {
TERMINAL_LIMITS,
type TerminalCreateRequest,
type TerminalErrorCode,
type TerminalEvent,
type TerminalSize,
type TerminalSnapshot
} from '../../shared/terminal-contracts'
import type { AssistantDatabase } from '../assistant/assistant-database'
import {
type ExecutionSpaceDescriptor,
type ExecutionSpaceResolver
} from '../execution-space/execution-space-resolver'
import type { RemoteAgentTargetResolver } from '../remote-agent/remote-agent-connection-manager'
import type {
SshConnectionPool,
SshConnectionPoolTarget
} from '../ssh/ssh-connection-pool'
import {
LocalTerminalSession,
type LocalTerminalSessionOptions
} from './local-terminal-session'
import {
createSshTerminalSession,
type SshTerminalSession,
type SshTerminalSessionOptions
} from './ssh-terminal-session'
export class TerminalSessionManagerError extends Error {
constructor(
readonly code: TerminalErrorCode,
message: string
) {
super(message)
this.name = 'TerminalSessionManagerError'
}
}
export type ManagedTerminalSession = {
snapshot(): TerminalSnapshot
write(data: string): boolean | void
resize(size: TerminalSize): boolean | void
acknowledge(sequence: number): void
close(): void | TerminalSnapshot | Promise<void | TerminalSnapshot>
onEvent?: (listener: (event: TerminalEvent) => void) => () => void
}
export type TerminalSessionManagerDependencies = {
database: Pick<AssistantDatabase, 'getProject'>
executionSpaceResolver: Pick<ExecutionSpaceResolver, 'resolveProject'>
targetResolver: RemoteAgentTargetResolver
sshPool: SshConnectionPool
remoteEnabled: () => boolean | Promise<boolean>
deliverEvent: (ownerWebContentsId: number, event: TerminalEvent) => void
createLocalSession?: (
options: LocalTerminalSessionOptions
) => Promise<ManagedTerminalSession>
createSshSession?: (
pool: SshConnectionPool,
options: SshTerminalSessionOptions
) => Promise<ManagedTerminalSession>
createSessionId?: () => string
}
type SessionRecord = {
ownerId: number
sessionId: string
deliveryEnabled: boolean
delayedEvents: TerminalEvent[]
closing: boolean
session?: ManagedTerminalSession
ready: Promise<ManagedTerminalSession>
closePromise?: Promise<TerminalSnapshot>
removeEventListener?: () => void
}
type ResolvedLaunch =
| {
kind: 'local'
targetLabel: string
projectDirectory?: string
}
| {
kind: 'ssh'
targetLabel: string
workingDirectory: string
poolTarget: SshConnectionPoolTarget
}
/**
* Owns ephemeral terminal sessions. Session identifiers are generated in Main
* and every operation is scoped to the creating WebContents identifier.
*/
export class TerminalSessionManager {
private readonly records = new Map<string, SessionRecord>()
private disposePromise: Promise<void> | undefined
private disposed = false
constructor(
private readonly dependencies: TerminalSessionManagerDependencies
) {}
async create(
ownerWebContentsId: number,
request: TerminalCreateRequest
): Promise<TerminalSnapshot> {
this.assertOwnerId(ownerWebContentsId)
if (this.disposed) {
throw new TerminalSessionManagerError(
'internal-error',
'终端会话管理器已关闭'
)
}
if (this.ownerSessionCount(ownerWebContentsId) >=
TERMINAL_LIMITS.maximumSessionsPerWindow) {
throw new TerminalSessionManagerError(
'session-limit-reached',
`每个窗口最多可同时打开 ${TERMINAL_LIMITS.maximumSessionsPerWindow} 个终端`
)
}
const sessionId =
this.dependencies.createSessionId?.() ?? randomUUID()
const record: SessionRecord = {
ownerId: ownerWebContentsId,
sessionId,
deliveryEnabled: false,
delayedEvents: [],
closing: false,
ready: Promise.resolve(undefined as never)
}
this.records.set(sessionId, record)
record.ready = this.createSession(record, request)
try {
const session = await record.ready
if (record.closing) {
return await this.closeRecord(record)
}
return session.snapshot()
} catch (error) {
if (this.records.get(sessionId) === record) {
this.records.delete(sessionId)
}
throw error
}
}
/**
* Enables event forwarding after the create response has crossed IPC. Events
* produced while a PTY/channel was starting are retained and delivered first.
*/
enableEventDelivery(
ownerWebContentsId: number,
sessionId: string
): void {
const record = this.requireOwned(ownerWebContentsId, sessionId)
if (record.deliveryEnabled) {
return
}
record.deliveryEnabled = true
for (const event of record.delayedEvents.splice(0)) {
this.deliver(record, event)
}
}
write(
ownerWebContentsId: number,
sessionId: string,
data: string
): boolean {
const record = this.requireOwned(ownerWebContentsId, sessionId)
const session = this.requireRunning(record)
return session.write(data) !== false
}
resize(
ownerWebContentsId: number,
sessionId: string,
size: TerminalSize
): boolean {
const record = this.requireOwned(ownerWebContentsId, sessionId)
const session = this.requireRunning(record)
return session.resize(size) !== false
}
snapshot(
ownerWebContentsId: number,
sessionId: string
): TerminalSnapshot {
const record = this.requireOwned(ownerWebContentsId, sessionId)
if (!record.session) {
throw new TerminalSessionManagerError(
'session-not-running',
'终端会话仍在启动'
)
}
return record.session.snapshot()
}
acknowledge(
ownerWebContentsId: number,
sessionId: string,
sequence: number
): void {
this.assertOwnerId(ownerWebContentsId)
const record = this.records.get(sessionId)
if (!record) {
// ACKs already queued in the renderer may arrive after a successful
// close. They are delivery bookkeeping, so treating them as complete is
// both safe and necessary for a race-free close path.
return
}
if (record.ownerId !== ownerWebContentsId) {
throw new TerminalSessionManagerError(
'session-not-found',
'终端会话不存在'
)
}
if (!record.session || record.closing) {
return
}
record.session.acknowledge(sequence)
}
close(
ownerWebContentsId: number,
sessionId: string
): Promise<TerminalSnapshot> {
const record = this.requireOwned(ownerWebContentsId, sessionId)
return this.closeRecord(record)
}
async closeOwner(ownerWebContentsId: number): Promise<void> {
this.assertOwnerId(ownerWebContentsId)
const records = [...this.records.values()].filter(
(record) => record.ownerId === ownerWebContentsId
)
await Promise.allSettled(records.map((record) => this.closeRecord(record)))
for (const record of records) {
record.removeEventListener?.()
if (this.records.get(record.sessionId) === record) {
this.records.delete(record.sessionId)
}
}
}
dispose(): Promise<void> {
this.disposePromise ??= this.disposeInternal()
return this.disposePromise
}
private async disposeInternal(): Promise<void> {
this.disposed = true
const records = [...this.records.values()]
await Promise.allSettled(records.map((record) => this.closeRecord(record)))
for (const record of records) {
record.removeEventListener?.()
}
this.records.clear()
}
private async createSession(
record: SessionRecord,
request: TerminalCreateRequest
): Promise<ManagedTerminalSession> {
const launch = await this.resolveLaunch(request)
const common = {
sessionId: record.sessionId,
target: request.target,
targetLabel: launch.targetLabel,
title: `终端 · ${launch.targetLabel}`,
size: { cols: request.cols, rows: request.rows }
}
let session: ManagedTerminalSession
if (launch.kind === 'local') {
session = await (
this.dependencies.createLocalSession ??
((options) => LocalTerminalSession.create(options))
)({
...common,
target: request.target as Extract<
TerminalCreateRequest['target'],
{ type: 'local' | 'project' }
>,
projectDirectory: launch.projectDirectory
})
record.session = session
record.removeEventListener = session.onEvent?.((event) =>
this.handleEvent(record, event)
)
} else {
session = await (
this.dependencies.createSshSession ??
((pool, options) =>
createSshTerminalSession(pool, options) as Promise<SshTerminalSession>)
)(this.dependencies.sshPool, {
...common,
workingDirectory: launch.workingDirectory,
poolTarget: launch.poolTarget,
onEvent: (event) => this.handleEvent(record, event)
})
record.session = session
}
return session
}
private async resolveLaunch(
request: TerminalCreateRequest
): Promise<ResolvedLaunch> {
if (request.target.type === 'local') {
return { kind: 'local', targetLabel: '本机' }
}
let project: ReturnType<AssistantDatabase['getProject']>
try {
project = this.dependencies.database.getProject(
request.target.projectId
)
} catch (error) {
throw this.resolutionError('target-not-found', error, '项目不存在')
}
let executionSpace: ExecutionSpaceDescriptor
try {
executionSpace =
this.dependencies.executionSpaceResolver.resolveProject(project)
} catch (error) {
throw this.resolutionError(
'target-unavailable',
error,
'项目执行空间不可用'
)
}
if (executionSpace.kind === 'local') {
return {
kind: 'local',
targetLabel: project.name,
projectDirectory: executionSpace.rootPath
}
}
await this.assertRemoteEnabled()
const poolTarget = await this.resolveTarget(executionSpace.hostId)
return {
kind: 'ssh',
targetLabel: poolTarget.host.name,
workingDirectory: executionSpace.remoteRootPath,
poolTarget
}
}
private async resolveTarget(
hostId: string
): Promise<SshConnectionPoolTarget> {
try {
return await this.dependencies.targetResolver.resolve(hostId)
} catch (error) {
throw this.resolutionError(
'target-unavailable',
error,
'SSH 主机不可用'
)
}
}
private async assertRemoteEnabled(): Promise<void> {
if (!(await this.dependencies.remoteEnabled())) {
throw new TerminalSessionManagerError(
'target-unavailable',
'远程终端当前未启用'
)
}
}
private handleEvent(record: SessionRecord, event: TerminalEvent): void {
if (this.records.get(record.sessionId) !== record) {
return
}
if (!record.deliveryEnabled) {
record.delayedEvents.push(event)
return
}
this.deliver(record, event)
}
private deliver(record: SessionRecord, event: TerminalEvent): void {
try {
this.dependencies.deliverEvent(record.ownerId, event)
} catch {
// A destroyed renderer is cleaned up by closeOwner; it must not break
// PTY/SSH event handling while that close is being scheduled.
}
}
private requireRunning(record: SessionRecord): ManagedTerminalSession {
const session = record.session
if (
!session ||
record.closing ||
session.snapshot().state !== 'running'
) {
throw new TerminalSessionManagerError(
'session-not-running',
'终端会话未在运行'
)
}
return session
}
private closeRecord(record: SessionRecord): Promise<TerminalSnapshot> {
record.closing = true
record.closePromise ??= (async () => {
try {
const session = await record.ready
const closed = await session.close()
const snapshot =
closed && typeof closed === 'object' && 'sessionId' in closed
? closed
: session.snapshot()
record.removeEventListener?.()
record.removeEventListener = undefined
record.delayedEvents.length = 0
if (this.records.get(record.sessionId) === record) {
this.records.delete(record.sessionId)
}
return snapshot
} catch (error) {
record.closing = false
record.closePromise = undefined
throw error
}
})()
return record.closePromise
}
private requireOwned(
ownerWebContentsId: number,
sessionId: string
): SessionRecord {
this.assertOwnerId(ownerWebContentsId)
const record = this.records.get(sessionId)
if (!record || record.ownerId !== ownerWebContentsId) {
throw new TerminalSessionManagerError(
'session-not-found',
'终端会话不存在'
)
}
return record
}
private ownerSessionCount(ownerId: number): number {
let count = 0
for (const record of this.records.values()) {
if (record.ownerId === ownerId && !record.closing) {
count += 1
}
}
return count
}
private assertOwnerId(ownerId: number): void {
if (!Number.isSafeInteger(ownerId) || ownerId < 0) {
throw new TypeError('无效的终端窗口标识')
}
}
private resolutionError(
code: TerminalErrorCode,
cause: unknown,
fallback: string
): TerminalSessionManagerError {
return new TerminalSessionManagerError(
code,
cause instanceof Error && cause.message
? cause.message
: fallback
)
}
}
+45
View File
@@ -166,6 +166,16 @@ import type {
RemoteProjectRecoverySnapshot,
RemoteProjectRecoveryState
} from '../shared/remote-project-recovery-contracts'
import type {
TerminalAckRequest,
TerminalCloseRequest,
TerminalCreateRequest,
TerminalEvent,
TerminalResizeRequest,
TerminalSnapshot,
TerminalSnapshotRequest,
TerminalWriteRequest
} from '../shared/terminal-contracts'
const desktopApi: DesktopApi = {
app: {
@@ -302,6 +312,41 @@ const desktopApi: DesktopApi = {
ipcRenderer.removeListener(ipcChannels.browserState, handler)
}
},
terminal: {
create: (request: TerminalCreateRequest) =>
ipcRenderer.invoke(
ipcChannels.terminalCreate,
request
) as Promise<TerminalSnapshot>,
write: async (request: TerminalWriteRequest) => {
await ipcRenderer.invoke(ipcChannels.terminalWrite, request)
},
resize: async (request: TerminalResizeRequest) => {
await ipcRenderer.invoke(ipcChannels.terminalResize, request)
},
close: (request: TerminalCloseRequest) =>
ipcRenderer.invoke(
ipcChannels.terminalClose,
request
) as Promise<TerminalSnapshot>,
getSnapshot: (request: TerminalSnapshotRequest) =>
ipcRenderer.invoke(
ipcChannels.terminalSnapshot,
request
) as Promise<TerminalSnapshot>,
ack: async (request: TerminalAckRequest) => {
await ipcRenderer.invoke(ipcChannels.terminalAck, request)
},
onEvent: (listener) => {
const handler = (
_event: Electron.IpcRendererEvent,
payload: TerminalEvent
): void => listener(payload)
ipcRenderer.on(ipcChannels.terminalEvent, handler)
return () =>
ipcRenderer.removeListener(ipcChannels.terminalEvent, handler)
}
},
settings: {
getRuntime: () =>
ipcRenderer.invoke(
+22
View File
@@ -228,6 +228,28 @@ describe('sandboxed preload', () => {
)
})
it('exposes only bounded terminal session operations', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
'utf8'
)
const terminal =
source.match(
/terminal: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}settings:/u
)?.groups?.body ?? ''
expect(terminal).toContain('create:')
expect(terminal).toContain('write:')
expect(terminal).toContain('resize:')
expect(terminal).toContain('close:')
expect(terminal).toContain('getSnapshot:')
expect(terminal).toContain('ack:')
expect(terminal).toContain('onEvent:')
expect(terminal).toContain('ipcRenderer.removeListener(')
expect(terminal).not.toMatch(
/\b(?:shell|cwd|command|environment|password|privateKey|pid)\b/u
)
})
it('exposes narrow awaited remote project activation and save operations', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
+155 -4
View File
@@ -18,6 +18,9 @@ import type {
} from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import type {
TerminalSnapshot
} from '../../shared/terminal-contracts'
import {
loadBrandingPreferences,
saveBrandingPreferences
@@ -99,6 +102,12 @@ vi.mock('./ActivityPanel', async (importOriginal) => {
return importOriginal<typeof import('./ActivityPanel')>()
})
vi.mock('./TerminalPanel', () => ({
TerminalPanel: ({ title }: { title?: string }) => (
<section aria-label={`用户终端:${title ?? '终端'}`} />
)
}))
import App from './App'
import { loadActivityRecords } from './activity-store'
import { changeUiLocale } from './i18n'
@@ -155,6 +164,20 @@ const project = {
updatedAt: '2026-07-31T00:00:00.000Z'
}
const terminalSnapshot: TerminalSnapshot = {
sessionId: '00000000-0000-4000-8000-000000000901',
target: { type: 'local' },
targetLabel: '本机',
title: '终端 1',
state: 'running',
shell: 'PowerShell 7',
workingDirectory: 'C:\\Users\\test',
size: { cols: 80, rows: 24 },
lastSequence: 0,
exit: null,
error: null
}
const api: DesktopApi = {
app: {
getInfo: vi.fn(async () => ({
@@ -231,6 +254,23 @@ const api: DesktopApi = {
}
})
},
terminal: {
create: vi.fn(async (request) => ({
...terminalSnapshot,
target: request.target,
size: { cols: request.cols, rows: request.rows }
})),
write: vi.fn(async () => undefined),
resize: vi.fn(async () => undefined),
close: vi.fn(async () => ({
...terminalSnapshot,
state: 'exited' as const,
exit: { exitCode: 0, signal: null }
})),
getSnapshot: vi.fn(async () => terminalSnapshot),
ack: vi.fn(async () => undefined),
onEvent: vi.fn(() => () => {})
},
speech: {
transcribe: vi.fn(async () => ({ text: '本地语音结果' })),
cancel: vi.fn(async () => true)
@@ -5462,6 +5502,35 @@ describe('App', () => {
).toBeInTheDocument()
})
it('opens multiple current-project terminals without a second target picker', async () => {
render(<App />)
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
const add = await screen.findByRole('button', {
name: '打开工作栏应用'
})
for (let count = 1; count <= 2; count += 1) {
fireEvent.click(add)
fireEvent.click(
await screen.findByRole('button', {
name: /打开当前项目的用户终端/u
})
)
expect(
await screen.findByRole('tab', {
name: `终端 ${count}`
})
).toHaveAttribute('aria-selected', 'true')
}
expect(
screen.getByRole('button', { name: '打开工作栏应用' })
).toBeVisible()
expect(
screen.getAllByRole('tab', { name: /^终端 \d+$/u })
).toHaveLength(2)
})
it('opens workspace entries from their row actions', async () => {
vi.mocked(api.workspace.listDirectory).mockResolvedValue({
path: '',
@@ -5683,7 +5752,7 @@ describe('App', () => {
).not.toBeInTheDocument()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
).toBeInTheDocument()
await waitFor(() =>
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce()
)
@@ -9567,7 +9636,7 @@ describe('App', () => {
).toBeInTheDocument()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
).toBeInTheDocument()
})
it('excludes ignored heartbeat suggestions from the navigation badge', async () => {
@@ -9907,6 +9976,88 @@ describe('App', () => {
}
})
it('resizes and remembers the docked primary sidebar width', () => {
const originalWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1200
})
const { container, unmount } = render(<App />)
try {
const sidebar =
container.querySelector<HTMLElement>('.sidebar')
const separator = screen.getByRole('separator', {
name: '调整主侧栏宽度'
})
const setPointerCapture = vi.fn()
const releasePointerCapture = vi.fn()
Object.defineProperties(separator, {
setPointerCapture: { value: setPointerCapture },
hasPointerCapture: { value: () => true },
releasePointerCapture: { value: releasePointerCapture }
})
expect(
sidebar?.style.getPropertyValue('--primary-sidebar-width')
).toBe('278px')
expect(separator).toHaveAttribute('aria-valuemin', '220')
expect(separator).toHaveAttribute('aria-valuemax', '420')
fireEvent.pointerDown(separator, {
button: 0,
clientX: 320,
pointerId: 17
})
fireEvent.pointerMove(separator, {
clientX: 400,
pointerId: 17
})
expect(setPointerCapture).toHaveBeenCalledWith(17)
expect(sidebar).toHaveClass('sidebar--resizing')
expect(
sidebar?.style.getPropertyValue('--primary-sidebar-width')
).toBe('400px')
fireEvent.pointerUp(separator, { pointerId: 17 })
expect(releasePointerCapture).toHaveBeenCalledWith(17)
expect(sidebar).not.toHaveClass('sidebar--resizing')
expect(
localStorage.getItem('goodbuddy.primary-sidebar-width.v1')
).toBe('400')
fireEvent.keyDown(separator, { key: 'Home' })
expect(
sidebar?.style.getPropertyValue('--primary-sidebar-width')
).toBe('220px')
fireEvent.keyDown(separator, { key: 'ArrowRight' })
expect(
sidebar?.style.getPropertyValue('--primary-sidebar-width')
).toBe('236px')
fireEvent.keyDown(separator, { key: 'End' })
expect(
sidebar?.style.getPropertyValue('--primary-sidebar-width')
).toBe('420px')
expect(separator).toHaveAttribute('aria-valuenow', '420')
unmount()
const persistedRender = render(<App />)
expect(
persistedRender.container
.querySelector<HTMLElement>('.sidebar')
?.style.getPropertyValue('--primary-sidebar-width')
).toBe('420px')
} finally {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: originalWidth
})
}
})
it('docks the assistant workbar without obscuring a narrow workspace', async () => {
const originalWidth = window.innerWidth
const { container } = render(<App />)
@@ -9994,7 +10145,7 @@ describe('App', () => {
)
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
).toBeInTheDocument()
} finally {
delete api.updates
}
@@ -10206,7 +10357,7 @@ describe('App', () => {
).toBeInTheDocument()
expect(
screen.queryByLabelText('切换助手工作栏')
).not.toBeInTheDocument()
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^专家角色:/u })
).not.toBeInTheDocument()
+247 -24
View File
@@ -558,9 +558,66 @@ const storageKey = 'goodbuddy.conversations.v1'
const activeProjectStorageKey = 'goodbuddy.active-project.v1'
const primarySidebarWidthStorageKey =
'goodbuddy.primary-sidebar-width.v1'
const defaultPrimarySidebarWidth = 278
const compactPrimarySidebarWidth = 236
const minimumPrimarySidebarWidth = 220
const maximumPrimarySidebarWidth = 420
const minimumPrimaryWorkspaceWidth = 480
const primarySidebarKeyboardResizeStep = 16
const maxMessageContentLength = 1_000_000
const maxMessageBlocks = 500
function getPrimarySidebarWidthLimits(viewportWidth: number): {
minimum: number
maximum: number
} {
const availableWidth = Math.max(0, Math.floor(viewportWidth))
return {
minimum: minimumPrimarySidebarWidth,
maximum: Math.max(
minimumPrimarySidebarWidth,
Math.min(
maximumPrimarySidebarWidth,
availableWidth - minimumPrimaryWorkspaceWidth
)
)
}
}
function clampPrimarySidebarWidth(
width: number,
viewportWidth: number
): number {
const limits = getPrimarySidebarWidthLimits(viewportWidth)
return Math.min(
limits.maximum,
Math.max(limits.minimum, Math.round(width))
)
}
function loadPrimarySidebarWidth(): number {
const fallback =
window.innerWidth <= 1020
? compactPrimarySidebarWidth
: defaultPrimarySidebarWidth
try {
const persistedWidth = Number(
localStorage.getItem(primarySidebarWidthStorageKey)
)
return clampPrimarySidebarWidth(
Number.isFinite(persistedWidth) && persistedWidth > 0
? persistedWidth
: fallback,
window.innerWidth
)
} catch {
return clampPrimarySidebarWidth(fallback, window.innerWidth)
}
}
function appendMessageContentBlock(
blocks: ConversationMessageBlock[] | undefined,
type: 'text' | 'reasoning',
@@ -2298,6 +2355,11 @@ function App(): React.JSX.Element {
const [sidebarOpen, setSidebarOpen] = useState(
() => window.innerWidth >= 900
)
const [primarySidebarWidth, setPrimarySidebarWidth] = useState(
loadPrimarySidebarWidth
)
const [primarySidebarResizing, setPrimarySidebarResizing] =
useState(false)
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
() => window.innerWidth >= 1280
)
@@ -2647,6 +2709,10 @@ function App(): React.JSX.Element {
>({})
const sidebarRef = useRef<HTMLElement>(null)
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
const livePrimarySidebarWidthRef = useRef(primarySidebarWidth)
const primarySidebarResizePointerIdRef = useRef<number | undefined>(
undefined
)
const assistantSidebarToggleRef = useRef<HTMLButtonElement>(null)
const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>()
@@ -2673,6 +2739,71 @@ function App(): React.JSX.Element {
setSidebarOpen(false)
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
}, [])
const resizePrimarySidebarFromClientX = useCallback(
(clientX: number, commit: boolean): void => {
const width = clampPrimarySidebarWidth(
clientX,
window.innerWidth
)
livePrimarySidebarWidthRef.current = width
if (commit) {
setPrimarySidebarWidth(width)
return
}
sidebarRef.current?.style.setProperty(
'--primary-sidebar-width',
`${width}px`
)
},
[]
)
const finishPrimarySidebarResize = useCallback(
(event: React.PointerEvent<HTMLDivElement>): void => {
if (
primarySidebarResizePointerIdRef.current !== event.pointerId
) {
return
}
primarySidebarResizePointerIdRef.current = undefined
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
setPrimarySidebarWidth(livePrimarySidebarWidthRef.current)
setPrimarySidebarResizing(false)
},
[]
)
const resizePrimarySidebarWithKeyboard = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>): void => {
if (narrowWindow || !sidebarOpen) {
return
}
const limits = getPrimarySidebarWidthLimits(window.innerWidth)
const nextWidth =
event.key === 'Home'
? limits.minimum
: event.key === 'End'
? limits.maximum
: event.key === 'ArrowLeft'
? primarySidebarWidth -
primarySidebarKeyboardResizeStep
: event.key === 'ArrowRight'
? primarySidebarWidth +
primarySidebarKeyboardResizeStep
: undefined
if (nextWidth === undefined) {
return
}
event.preventDefault()
const width = clampPrimarySidebarWidth(
nextWidth,
window.innerWidth
)
livePrimarySidebarWidthRef.current = width
setPrimarySidebarWidth(width)
},
[narrowWindow, primarySidebarWidth, sidebarOpen]
)
const navigateFromSidebar = useCallback(
(nextView: WorkspaceView, trigger: HTMLElement): void => {
if (viewRef.current !== 'settings' && nextView === 'settings') {
@@ -2788,12 +2919,30 @@ function App(): React.JSX.Element {
return () => window.clearInterval(interval)
}, [activeId, assistantTasks, knowledgeOperationCount, view])
useEffect(() => {
livePrimarySidebarWidthRef.current = primarySidebarWidth
try {
localStorage.setItem(
primarySidebarWidthStorageKey,
String(primarySidebarWidth)
)
} catch {
// The current width remains usable when browser storage is unavailable.
}
}, [primarySidebarWidth])
useEffect(() => {
const collapseSidebarAtNarrowWidth = (): void => {
const narrow = window.innerWidth < 900
setNarrowWindow(narrow)
if (narrow) {
setSidebarOpen(false)
setPrimarySidebarResizing(false)
primarySidebarResizePointerIdRef.current = undefined
} else {
setPrimarySidebarWidth((current) =>
clampPrimarySidebarWidth(current, window.innerWidth)
)
}
}
window.addEventListener('resize', collapseSidebarAtNarrowWidth)
@@ -8033,6 +8182,9 @@ function App(): React.JSX.Element {
])
const mainSidebarOpen = narrowWindow && sidebarOpen
const canResizePrimarySidebar = sidebarOpen && !narrowWindow
const primarySidebarWidthLimits =
getPrimarySidebarWidthLimits(window.innerWidth)
const backgroundIsolated = mainSidebarOpen
const runtimeState =
runtimeSwitching ||
@@ -8060,7 +8212,17 @@ function App(): React.JSX.Element {
}
aria-hidden={!sidebarOpen}
aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined}
className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}
className={
sidebarOpen
? `sidebar${
primarySidebarResizing &&
canResizePrimarySidebar
? ' sidebar--resizing'
: ''
}`
: 'sidebar sidebar--closed'
}
id="primary-sidebar"
inert={!sidebarOpen}
onKeyDown={(event) => {
if (mainSidebarOpen) {
@@ -8069,6 +8231,11 @@ function App(): React.JSX.Element {
}}
ref={sidebarRef}
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
style={
{
'--primary-sidebar-width': `${primarySidebarWidth}px`
} as React.CSSProperties
}
>
<BrandLockup
className="brand"
@@ -8757,6 +8924,65 @@ function App(): React.JSX.Element {
</button>
</div>
</aside>
{sidebarOpen && (
<div
aria-controls="primary-sidebar"
aria-disabled={!canResizePrimarySidebar}
aria-label={t('sidebar.resizeAriaLabel')}
aria-orientation="vertical"
aria-valuemax={primarySidebarWidthLimits.maximum}
aria-valuemin={primarySidebarWidthLimits.minimum}
aria-valuenow={primarySidebarWidth}
aria-valuetext={t('sidebar.resizeValue', {
width: primarySidebarWidth
})}
className="primary-sidebar-resize-handle"
onKeyDown={resizePrimarySidebarWithKeyboard}
onLostPointerCapture={(event) => {
if (
primarySidebarResizePointerIdRef.current ===
event.pointerId
) {
primarySidebarResizePointerIdRef.current = undefined
setPrimarySidebarWidth(
livePrimarySidebarWidthRef.current
)
setPrimarySidebarResizing(false)
}
}}
onPointerCancel={finishPrimarySidebarResize}
onPointerDown={(event) => {
if (
event.button !== 0 ||
!canResizePrimarySidebar
) {
return
}
event.preventDefault()
primarySidebarResizePointerIdRef.current = event.pointerId
event.currentTarget.setPointerCapture(event.pointerId)
resizePrimarySidebarFromClientX(event.clientX, true)
setPrimarySidebarResizing(true)
}}
onPointerMove={(event) => {
if (
primarySidebarResizePointerIdRef.current !==
event.pointerId
) {
return
}
if (!canResizePrimarySidebar) {
finishPrimarySidebarResize(event)
return
}
event.preventDefault()
resizePrimarySidebarFromClientX(event.clientX, false)
}}
onPointerUp={finishPrimarySidebarResize}
role="separator"
tabIndex={canResizePrimarySidebar ? 0 : -1}
/>
)}
{sidebarOpen && (
<button
aria-label={t('sidebar.close')}
@@ -8882,26 +9108,24 @@ function App(): React.JSX.Element {
</header>
<div className="app-content">
{view === 'chat' && (
<button
aria-controls="assistant-sidebar"
aria-expanded={assistantSidebarOpen}
aria-label={t('topbar.toggleAssistantSidebar')}
className="icon-button assistant-sidebar-toggle"
onClick={() =>
setAssistantSidebarOpen((current) => !current)
}
ref={assistantSidebarToggleRef}
title={t('topbar.toggleAssistantSidebar')}
type="button"
>
{assistantSidebarOpen ? (
<PanelRightClose aria-hidden="true" size={18} />
) : (
<PanelRightOpen aria-hidden="true" size={18} />
)}
</button>
)}
<button
aria-controls="assistant-sidebar"
aria-expanded={assistantSidebarOpen}
aria-label={t('topbar.toggleAssistantSidebar')}
className="icon-button assistant-sidebar-toggle"
onClick={() =>
setAssistantSidebarOpen((current) => !current)
}
ref={assistantSidebarToggleRef}
title={t('topbar.toggleAssistantSidebar')}
type="button"
>
{assistantSidebarOpen ? (
<PanelRightClose aria-hidden="true" size={18} />
) : (
<PanelRightOpen aria-hidden="true" size={18} />
)}
</button>
<main
aria-hidden={backgroundIsolated ? 'true' : undefined}
@@ -10602,6 +10826,7 @@ function App(): React.JSX.Element {
artifacts={sidebarArtifacts}
browserState={browserStates[activeId]}
conversationTitles={conversationTitles}
currentProject={activeProject}
onCreateCustomTask={() => openCustomTaskDialog('current')}
schedules={assistantSchedules}
selectedTaskId={selectedAssistantTaskId}
@@ -10682,9 +10907,7 @@ function App(): React.JSX.Element {
onOpenWorkspaceEntry={openWorkspaceEntry}
onRefreshChanges={refreshWorkspaceChanges}
onTabChange={setAssistantSidebarTab}
open={
assistantSidebarOpen && view === 'chat'
}
open={assistantSidebarOpen}
restoreFocusRef={assistantSidebarToggleRef}
tab={assistantSidebarTab}
workspaceChanges={workspaceChanges}
@@ -198,6 +198,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
const onNotify = vi.fn()
beforeEach(() => {
localStorage.clear()
analysisEventListener = undefined
getApplicationSettings.mockResolvedValue({
checkUpdatesOnStartup: false,
@@ -528,6 +529,155 @@ describe('MagicNotesWorkspace', () => {
expect(pane).toBeVisible()
})
it('hides, restores, and remembers the left list pane', async () => {
const firstRender = render(
<MagicNotesWorkspace onNotify={onNotify} />
)
const pane = await screen.findByLabelText('笔记列表')
const hideButton = screen.getByRole('button', {
name: '隐藏左侧列表'
})
expect(hideButton).toHaveAttribute('aria-expanded', 'true')
expect(pane).toBeVisible()
fireEvent.click(hideButton)
expect(pane).not.toBeVisible()
expect(
screen.queryByRole('separator', {
name: '调整笔记列表与编辑区宽度'
})
).not.toBeInTheDocument()
expect(
JSON.parse(
localStorage.getItem('goodbuddy.magic-notes-layout.v1') ?? '{}'
)
).toMatchObject({ listPaneOpen: false })
firstRender.unmount()
render(<MagicNotesWorkspace onNotify={onNotify} />)
const restoredPane = await screen.findByLabelText('笔记列表')
expect(restoredPane).not.toBeVisible()
const showButton = screen.getByRole('button', {
name: '显示左侧列表'
})
expect(showButton).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(showButton)
expect(restoredPane).toBeVisible()
expect(
screen.getByRole('separator', {
name: '调整笔记列表与编辑区宽度'
})
).toBeInTheDocument()
})
it('resizes the left list pane with pointer and keyboard controls', async () => {
const originalInnerWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1200
})
render(<MagicNotesWorkspace onNotify={onNotify} />)
try {
await screen.findByText('记录正文')
const separator = screen.getByRole('separator', {
name: '调整笔记列表与编辑区宽度'
})
const layout =
separator.closest('.magic-notes-layout') as HTMLElement
const setPointerCapture = vi.fn()
const releasePointerCapture = vi.fn()
Object.defineProperties(separator, {
setPointerCapture: { value: setPointerCapture },
hasPointerCapture: { value: () => true },
releasePointerCapture: { value: releasePointerCapture }
})
expect(separator).toHaveAttribute('aria-valuenow', '220')
expect(separator).toHaveAttribute('aria-valuemin', '180')
expect(separator).toHaveAttribute('aria-valuemax', '360')
fireEvent.pointerDown(separator, {
button: 0,
clientX: 220,
pointerId: 13
})
fireEvent.pointerMove(separator, {
clientX: 350,
pointerId: 13
})
expect(setPointerCapture).toHaveBeenCalledWith(13)
expect(layout).toHaveClass('magic-notes-layout--resizing')
expect(layout).toHaveClass('magic-notes-layout--list-resizing')
expect(
layout.style.getPropertyValue('--magic-notes-list-width')
).toBe('350px')
fireEvent.pointerUp(separator, { pointerId: 13 })
expect(releasePointerCapture).toHaveBeenCalledWith(13)
expect(layout).not.toHaveClass('magic-notes-layout--resizing')
expect(
JSON.parse(
localStorage.getItem(
'goodbuddy.magic-notes-layout.v1'
) ?? '{}'
)
).toMatchObject({ listPaneWidth: 350 })
fireEvent.keyDown(separator, { key: 'Home' })
expect(
layout.style.getPropertyValue('--magic-notes-list-width')
).toBe('180px')
fireEvent.keyDown(separator, { key: 'ArrowRight' })
expect(
layout.style.getPropertyValue('--magic-notes-list-width')
).toBe('196px')
fireEvent.keyDown(separator, { key: 'End' })
expect(
layout.style.getPropertyValue('--magic-notes-list-width')
).toBe('360px')
expect(separator).toHaveAttribute('aria-valuenow', '360')
} finally {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: originalInnerWidth
})
}
})
it('disables horizontal pane resizing in the narrow stacked layout', async () => {
const originalInnerWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 700
})
render(<MagicNotesWorkspace onNotify={onNotify} />)
try {
await screen.findByText('记录正文')
const listSeparator = screen.getByRole('separator', {
name: '调整笔记列表与编辑区宽度'
})
expect(listSeparator).toHaveAttribute('aria-disabled', 'true')
expect(listSeparator).toHaveAttribute('tabindex', '-1')
const aiSeparator = screen.getByRole('separator', {
name: '调整编辑区与 AI 评论宽度'
})
expect(aiSeparator).toHaveAttribute('aria-disabled', 'true')
expect(aiSeparator).toHaveAttribute('tabindex', '-1')
} finally {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: originalInnerWidth
})
}
})
it('shows a concise empty state before a note has AI comments', async () => {
let resolveDetail:
| ((value: MagicNoteDetail) => void)
+409 -28
View File
@@ -8,6 +8,8 @@ import {
FolderTree,
Lightbulb,
ListTodo,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
Pin,
@@ -73,23 +75,107 @@ type DraftSwitchTarget =
const defaultAiPaneWidth = 280
const minimumAiPaneWidth = 240
const maximumAiPaneWidth = 520
const magicNotesListPaneWidth = 220
const defaultMagicNotesListPaneWidth = 220
const minimumMagicNotesListPaneWidth = 180
const maximumMagicNotesListPaneWidth = 360
const minimumMagicNotesEditorWidth = 300
const magicNotesResizeHandleWidth = 9
const aiPaneKeyboardResizeStep = 16
const magicNotesPaneKeyboardResizeStep = 16
const magicNotesLayoutStorageKey =
'goodbuddy.magic-notes-layout.v1'
function getAiPaneWidthLimits(layoutWidth: number): {
type MagicNotesLayoutPreferences = {
listPaneOpen: boolean
listPaneWidth: number
aiPaneOpen: boolean
aiPaneWidth: number
}
function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
const defaults = {
listPaneOpen: true,
listPaneWidth: defaultMagicNotesListPaneWidth,
aiPaneOpen: true,
aiPaneWidth: defaultAiPaneWidth
}
try {
const value = localStorage.getItem(magicNotesLayoutStorageKey)
if (!value || value.length > 10_000) {
return defaults
}
const parsed = JSON.parse(value) as Partial<
MagicNotesLayoutPreferences
>
return {
listPaneOpen: parsed.listPaneOpen !== false,
listPaneWidth:
typeof parsed.listPaneWidth === 'number' &&
Number.isFinite(parsed.listPaneWidth)
? Math.min(
maximumMagicNotesListPaneWidth,
Math.max(
minimumMagicNotesListPaneWidth,
parsed.listPaneWidth
)
)
: defaults.listPaneWidth,
aiPaneOpen: parsed.aiPaneOpen !== false,
aiPaneWidth:
typeof parsed.aiPaneWidth === 'number' &&
Number.isFinite(parsed.aiPaneWidth)
? Math.min(
maximumAiPaneWidth,
Math.max(minimumAiPaneWidth, parsed.aiPaneWidth)
)
: defaults.aiPaneWidth
}
} catch {
return defaults
}
}
function persistMagicNotesLayoutPreferences(
preferences: MagicNotesLayoutPreferences
): void {
try {
localStorage.setItem(
magicNotesLayoutStorageKey,
JSON.stringify(preferences)
)
} catch {
// The in-memory layout remains usable when browser storage is unavailable.
}
}
type MagicNotesPaneWidthLimits = {
minimum: number
maximum: number
} {
}
function getMagicNotesPaneWidthLimits({
adjacentPaneOpen,
adjacentPaneWidth,
layoutWidth,
maximum,
minimum
}: {
adjacentPaneOpen: boolean
adjacentPaneWidth: number
layoutWidth: number
maximum: number
minimum: number
}): MagicNotesPaneWidthLimits {
const reservedAdjacentWidth = adjacentPaneOpen
? adjacentPaneWidth + magicNotesResizeHandleWidth
: 0
return {
minimum: minimumAiPaneWidth,
minimum,
maximum: Math.max(
minimumAiPaneWidth,
minimum,
Math.min(
maximumAiPaneWidth,
maximum,
layoutWidth -
magicNotesListPaneWidth -
reservedAdjacentWidth -
minimumMagicNotesEditorWidth -
magicNotesResizeHandleWidth
)
@@ -97,9 +183,73 @@ function getAiPaneWidthLimits(layoutWidth: number): {
}
}
function clampAiPaneWidth(width: number, layoutWidth: number): number {
const limits = getAiPaneWidthLimits(layoutWidth)
return Math.min(limits.maximum, Math.max(limits.minimum, width))
function clampMagicNotesPaneWidth(
width: number,
limits: MagicNotesPaneWidthLimits
): number {
return Math.round(
Math.min(limits.maximum, Math.max(limits.minimum, width))
)
}
function getAiPaneWidthLimits(
layoutWidth: number,
listPaneWidth: number,
listPaneOpen: boolean
): MagicNotesPaneWidthLimits {
return getMagicNotesPaneWidthLimits({
adjacentPaneOpen: listPaneOpen,
adjacentPaneWidth: listPaneWidth,
layoutWidth,
maximum: maximumAiPaneWidth,
minimum: minimumAiPaneWidth
})
}
function clampAiPaneWidth(
width: number,
layoutWidth: number,
listPaneWidth: number,
listPaneOpen: boolean
): number {
return clampMagicNotesPaneWidth(
width,
getAiPaneWidthLimits(
layoutWidth,
listPaneWidth,
listPaneOpen
)
)
}
function getListPaneWidthLimits(
layoutWidth: number,
aiPaneWidth: number,
aiPaneOpen: boolean
): MagicNotesPaneWidthLimits {
return getMagicNotesPaneWidthLimits({
adjacentPaneOpen: aiPaneOpen,
adjacentPaneWidth: aiPaneWidth,
layoutWidth,
maximum: maximumMagicNotesListPaneWidth,
minimum: minimumMagicNotesListPaneWidth
})
}
function clampListPaneWidth(
width: number,
layoutWidth: number,
aiPaneWidth: number,
aiPaneOpen: boolean
): number {
return clampMagicNotesPaneWidth(
width,
getListPaneWidthLimits(
layoutWidth,
aiPaneWidth,
aiPaneOpen
)
)
}
function noteSummary(note: MagicNoteDetail): MagicNoteSummary {
@@ -349,8 +499,23 @@ export function MagicNotesWorkspace({
const [composerKey, setComposerKey] = useState(0)
const [editingEntry, setEditingEntry] = useState<MagicNoteEntry>()
const [deletingEntryId, setDeletingEntryId] = useState('')
const [aiPaneOpen, setAiPaneOpen] = useState(true)
const [aiPaneWidth, setAiPaneWidth] = useState(defaultAiPaneWidth)
const initialLayoutPreferences = useMemo(
() => loadMagicNotesLayoutPreferences(),
[]
)
const [listPaneOpen, setListPaneOpen] = useState(
initialLayoutPreferences.listPaneOpen
)
const [listPaneWidth, setListPaneWidth] = useState(
initialLayoutPreferences.listPaneWidth
)
const [listPaneResizing, setListPaneResizing] = useState(false)
const [aiPaneOpen, setAiPaneOpen] = useState(
initialLayoutPreferences.aiPaneOpen
)
const [aiPaneWidth, setAiPaneWidth] = useState(
initialLayoutPreferences.aiPaneWidth
)
const [aiPaneResizing, setAiPaneResizing] = useState(false)
const [magicNotesLayoutWidth, setMagicNotesLayoutWidth] = useState(
window.innerWidth
@@ -392,7 +557,9 @@ export function MagicNotesWorkspace({
const draftAnalysisContextRef = useRef(0)
const lastDraftAnalysisStartedAtRef = useRef(0)
const magicNotesLayoutRef = useRef<HTMLDivElement>(null)
const liveAiPaneWidthRef = useRef(defaultAiPaneWidth)
const liveListPaneWidthRef = useRef(listPaneWidth)
const listResizePointerIdRef = useRef<number | undefined>(undefined)
const liveAiPaneWidthRef = useRef(aiPaneWidth)
const aiResizePointerIdRef = useRef<number | undefined>(undefined)
const composerRef = useRef<HTMLDivElement>(null)
const continueEditingRef = useRef<HTMLButtonElement>(null)
@@ -426,12 +593,14 @@ export function MagicNotesWorkspace({
)
const getLayoutBounds = useCallback((): {
left: number
width: number
right: number
} => {
const bounds = magicNotesLayoutRef.current?.getBoundingClientRect()
const width = bounds?.width || window.innerWidth
return {
left: bounds?.left ?? 0,
width,
right: bounds?.right || width
}
@@ -442,7 +611,9 @@ export function MagicNotesWorkspace({
const bounds = getLayoutBounds()
const width = clampAiPaneWidth(
bounds.right - clientX,
bounds.width
bounds.width,
liveListPaneWidthRef.current,
listPaneOpen
)
liveAiPaneWidthRef.current = width
if (commit) {
@@ -454,7 +625,7 @@ export function MagicNotesWorkspace({
`${width}px`
)
},
[getLayoutBounds]
[getLayoutBounds, listPaneOpen]
)
const finishAiPaneResize = useCallback(
@@ -475,26 +646,106 @@ export function MagicNotesWorkspace({
const resizeAiPaneWithKeyboard = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>): void => {
const bounds = getLayoutBounds()
const limits = getAiPaneWidthLimits(bounds.width)
const limits = getAiPaneWidthLimits(
bounds.width,
listPaneWidth,
listPaneOpen
)
const nextWidth =
event.key === 'Home'
? limits.minimum
: event.key === 'End'
? limits.maximum
: event.key === 'ArrowLeft'
? aiPaneWidth + aiPaneKeyboardResizeStep
? aiPaneWidth + magicNotesPaneKeyboardResizeStep
: event.key === 'ArrowRight'
? aiPaneWidth - aiPaneKeyboardResizeStep
? aiPaneWidth - magicNotesPaneKeyboardResizeStep
: undefined
if (nextWidth === undefined) {
return
}
event.preventDefault()
const width = clampAiPaneWidth(nextWidth, bounds.width)
const width = clampAiPaneWidth(
nextWidth,
bounds.width,
listPaneWidth,
listPaneOpen
)
liveAiPaneWidthRef.current = width
setAiPaneWidth(width)
},
[aiPaneWidth, getLayoutBounds]
[aiPaneWidth, getLayoutBounds, listPaneOpen, listPaneWidth]
)
const resizeListPaneFromClientX = useCallback(
(clientX: number, commit: boolean): void => {
const bounds = getLayoutBounds()
const width = clampListPaneWidth(
clientX - bounds.left,
bounds.width,
liveAiPaneWidthRef.current,
aiPaneOpen
)
liveListPaneWidthRef.current = width
if (commit) {
setListPaneWidth(width)
return
}
magicNotesLayoutRef.current?.style.setProperty(
'--magic-notes-list-width',
`${width}px`
)
},
[aiPaneOpen, getLayoutBounds]
)
const finishListPaneResize = useCallback(
(event: React.PointerEvent<HTMLDivElement>): void => {
if (listResizePointerIdRef.current !== event.pointerId) {
return
}
listResizePointerIdRef.current = undefined
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
setListPaneWidth(liveListPaneWidthRef.current)
setListPaneResizing(false)
},
[]
)
const resizeListPaneWithKeyboard = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>): void => {
const bounds = getLayoutBounds()
const limits = getListPaneWidthLimits(
bounds.width,
aiPaneWidth,
aiPaneOpen
)
const nextWidth =
event.key === 'Home'
? limits.minimum
: event.key === 'End'
? limits.maximum
: event.key === 'ArrowLeft'
? listPaneWidth - magicNotesPaneKeyboardResizeStep
: event.key === 'ArrowRight'
? listPaneWidth + magicNotesPaneKeyboardResizeStep
: undefined
if (nextWidth === undefined) {
return
}
event.preventDefault()
const width = clampListPaneWidth(
nextWidth,
bounds.width,
aiPaneWidth,
aiPaneOpen
)
liveListPaneWidthRef.current = width
setListPaneWidth(width)
},
[aiPaneOpen, aiPaneWidth, getLayoutBounds, listPaneWidth]
)
useEffect(
@@ -512,33 +763,62 @@ export function MagicNotesWorkspace({
[]
)
useEffect(() => {
persistMagicNotesLayoutPreferences({
listPaneOpen,
listPaneWidth,
aiPaneOpen,
aiPaneWidth
})
}, [aiPaneOpen, aiPaneWidth, listPaneOpen, listPaneWidth])
useEffect(() => {
const layout = magicNotesLayoutRef.current
const updateLayoutWidth = (): void => {
const width = layout?.getBoundingClientRect().width || window.innerWidth
setMagicNotesLayoutWidth(width)
if (width > 720) {
setListPaneWidth((current) => {
const next = clampListPaneWidth(
current,
width,
liveAiPaneWidthRef.current,
aiPaneOpen
)
liveListPaneWidthRef.current = next
return next
})
}
if (width > 800) {
setAiPaneWidth((current) => {
const next = clampAiPaneWidth(current, width)
const next = clampAiPaneWidth(
current,
width,
liveListPaneWidthRef.current,
listPaneOpen
)
liveAiPaneWidthRef.current = next
return next
})
}
}
updateLayoutWidth()
window.addEventListener('resize', updateLayoutWidth)
const observer =
layout && typeof ResizeObserver !== 'undefined'
? new ResizeObserver(updateLayoutWidth)
: undefined
if (layout && observer) {
observer.observe(layout)
} else {
window.addEventListener('resize', updateLayoutWidth)
}
return () => {
window.removeEventListener('resize', updateLayoutWidth)
if (!observer) {
window.removeEventListener('resize', updateLayoutWidth)
}
observer?.disconnect()
}
}, [])
}, [aiPaneOpen, listPaneOpen])
const notifyError = useCallback(
(error: unknown): void =>
@@ -1136,8 +1416,24 @@ export function MagicNotesWorkspace({
.reverse(),
[detail]
)
const aiPaneWidthLimits = getAiPaneWidthLimits(magicNotesLayoutWidth)
const canResizeAiPane = aiPaneOpen && magicNotesLayoutWidth > 800
const listPaneWidthLimits = getListPaneWidthLimits(
magicNotesLayoutWidth,
aiPaneWidth,
aiPaneOpen
)
const aiPaneWidthLimits = getAiPaneWidthLimits(
magicNotesLayoutWidth,
listPaneWidth,
listPaneOpen
)
const canResizeListPane =
listPaneOpen &&
magicNotesLayoutWidth > 720 &&
listPaneWidthLimits.maximum > listPaneWidthLimits.minimum
const canResizeAiPane =
aiPaneOpen &&
magicNotesLayoutWidth > 800 &&
aiPaneWidthLimits.maximum > aiPaneWidthLimits.minimum
const submitCreateNote = async (): Promise<void> => {
const title = newTitle.trim()
@@ -1419,6 +1715,24 @@ export function MagicNotesWorkspace({
<PageHeader
actions={
<>
<button
aria-controls="magic-notes-list-pane"
aria-expanded={listPaneOpen}
className="secondary-button"
onClick={() => setListPaneOpen((current) => !current)}
type="button"
>
{listPaneOpen ? (
<PanelLeftClose aria-hidden="true" size={15} />
) : (
<PanelLeftOpen aria-hidden="true" size={15} />
)}
{t(
listPaneOpen
? 'actions.hideListPane'
: 'actions.showListPane'
)}
</button>
<button
aria-controls="magic-notes-ai-pane"
aria-expanded={aiPaneOpen}
@@ -1497,14 +1811,26 @@ export function MagicNotesWorkspace({
ref={magicNotesLayoutRef}
aria-busy={Boolean(busy)}
className={`magic-notes-layout${
listPaneOpen ? '' : ' magic-notes-layout--list-hidden'
}${
aiPaneOpen ? '' : ' magic-notes-layout--ai-hidden'
}${
aiPaneResizing && canResizeAiPane
(listPaneResizing && canResizeListPane) ||
(aiPaneResizing && canResizeAiPane)
? ' magic-notes-layout--resizing'
: ''
}${
listPaneResizing && canResizeListPane
? ' magic-notes-layout--list-resizing'
: ''
}${
aiPaneResizing && canResizeAiPane
? ' magic-notes-layout--ai-resizing'
: ''
}`}
style={
{
'--magic-notes-list-width': `${listPaneWidth}px`,
'--magic-notes-ai-width': `${aiPaneWidth}px`
} as React.CSSProperties
}
@@ -1516,6 +1842,8 @@ export function MagicNotesWorkspace({
: 'todos.listLabel'
)}
className="magic-notes-list-pane"
hidden={!listPaneOpen}
id="magic-notes-list-pane"
>
<PageTabs
ariaLabel={t('page.contentLabel')}
@@ -1772,6 +2100,59 @@ export function MagicNotesWorkspace({
)}
</aside>
{listPaneOpen && (
<div
aria-controls="magic-notes-list-pane"
aria-disabled={!canResizeListPane}
aria-label={t('accessibility.resizeListPane')}
aria-orientation="vertical"
aria-valuemax={listPaneWidthLimits.maximum}
aria-valuemin={listPaneWidthLimits.minimum}
aria-valuenow={listPaneWidth}
aria-valuetext={t('accessibility.listPaneWidth', {
width: listPaneWidth
})}
className="magic-notes-list-resize-handle"
onKeyDown={resizeListPaneWithKeyboard}
onLostPointerCapture={(event) => {
if (
listResizePointerIdRef.current === event.pointerId
) {
listResizePointerIdRef.current = undefined
setListPaneWidth(liveListPaneWidthRef.current)
setListPaneResizing(false)
}
}}
onPointerCancel={finishListPaneResize}
onPointerDown={(event) => {
if (event.button !== 0 || !canResizeListPane) {
return
}
event.preventDefault()
listResizePointerIdRef.current = event.pointerId
event.currentTarget.setPointerCapture(event.pointerId)
resizeListPaneFromClientX(event.clientX, true)
setListPaneResizing(true)
}}
onPointerMove={(event) => {
if (
listResizePointerIdRef.current !== event.pointerId
) {
return
}
if (!canResizeListPane) {
finishListPaneResize(event)
return
}
event.preventDefault()
resizeListPaneFromClientX(event.clientX, false)
}}
onPointerUp={finishListPaneResize}
role="separator"
tabIndex={canResizeListPane ? 0 : -1}
/>
)}
<section
aria-label={t(
libraryView === 'notes'
@@ -18,6 +18,7 @@ import {
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1400
+479 -82
View File
@@ -12,11 +12,20 @@ import {
ShieldAlert,
Upload
} from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
AssistantSchedule,
AssistantTask,
AssistantProject,
WorkspaceChanges,
WorkspaceDirectoryListing,
WorkspaceFilePreview
@@ -32,6 +41,28 @@ import {
findTaskSchedule,
TaskScheduleActions
} from './TaskScheduleActions'
import type {
TerminalSnapshot
} from '../../shared/terminal-contracts'
import {
WORKBAR_APP_DEFINITIONS,
workbarLayoutPreferencesSchema,
type WorkbarAppDefinition,
type WorkbarTabInstance,
type WorkbarTargetRef
} from '../../shared/workbar-contracts'
import {
DEFAULT_WORKBAR_INSTANCES,
WorkbarShell,
type WorkbarInstanceCreateRequest
} from './WorkbarShell'
import type { TerminalAdapter } from './TerminalPanel'
import './terminal-panel.css'
const TerminalPanel = lazy(async () => {
const module = await import('./TerminalPanel')
return { default: module.TerminalPanel }
})
export type AssistantSidebarTab =
| 'tasks'
@@ -69,6 +100,7 @@ type RightAssistantSidebarProps = {
workspaceChanges?: WorkspaceChanges
workspaceProjectId?: string
browserState?: BrowserLiveState
currentProject?: AssistantProject
restoreFocusRef?: { current: HTMLElement | null }
onInteractBrowser: () => Promise<void>
onStopBrowser: () => Promise<void>
@@ -108,6 +140,45 @@ const emptyChangedFiles: WorkspaceChanges['files'] = []
const defaultSidebarRatio = 0.3
const minimumPaneWidth = 300
const keyboardResizeStep = 16
const workbarStorageKey = 'goodbuddy.workbar-layout.v1'
function loadPersistedWorkbarLayout(): ReturnType<
typeof workbarLayoutPreferencesSchema.parse
> | undefined {
try {
const value = localStorage.getItem(workbarStorageKey)
if (!value || value.length > 100_000) {
return undefined
}
return workbarLayoutPreferencesSchema.parse(JSON.parse(value))
} catch {
return undefined
}
}
function persistWorkbarLayout(
instances: readonly WorkbarTabInstance[],
activeInstanceId: string | null,
expanded: boolean,
widthRatio: number
): void {
try {
localStorage.setItem(
workbarStorageKey,
JSON.stringify(
workbarLayoutPreferencesSchema.parse({
instances,
activeInstanceId,
expanded,
dock: 'right',
widthRatio
})
)
)
} catch {
// The in-memory workbar remains usable when browser storage is unavailable.
}
}
function getSidebarWidthLimits(layoutWidth: number): {
minimum: number
@@ -170,6 +241,7 @@ export function RightAssistantSidebar({
workspaceChanges,
workspaceProjectId,
browserState,
currentProject,
restoreFocusRef,
onInteractBrowser,
onStopBrowser,
@@ -206,10 +278,68 @@ export function RightAssistantSidebar({
})),
[t]
)
const initialLayout = useMemo(
() => loadPersistedWorkbarLayout(),
[]
)
const localizedAppDefinitions = useMemo(
() =>
WORKBAR_APP_DEFINITIONS.map((definition) => {
const tabDefinition = tabs.find(
(item) => item.id === definition.id
)
return {
...definition,
label:
tabDefinition?.label ??
t('sidebar.tabs.terminal.label'),
description:
tabDefinition?.description ??
t('sidebar.tabs.terminal.description')
} satisfies WorkbarAppDefinition
}),
[t, tabs]
)
const defaultInstances = useMemo(
() =>
DEFAULT_WORKBAR_INSTANCES.map((instance) => ({
...instance,
title:
localizedAppDefinitions.find(
(definition) => definition.id === instance.appId
)?.label ?? instance.title
})),
[localizedAppDefinitions]
)
const [workbarInstances, setWorkbarInstances] = useState<
WorkbarTabInstance[]
>(() =>
(initialLayout?.instances ?? defaultInstances).map((instance) =>
instance.appId === 'terminal'
? instance
: {
...instance,
title:
localizedAppDefinitions.find(
(definition) => definition.id === instance.appId
)?.label ?? instance.title
}
)
)
const [activeWorkbarInstanceId, setActiveWorkbarInstanceId] =
useState<string | null>(
() =>
initialLayout?.activeInstanceId ??
defaultInstances.find((instance) => instance.appId === tab)?.id ??
defaultInstances[0]?.id ??
null
)
const [splitLayoutWidth, setSplitLayoutWidth] = useState(
window.innerWidth
)
const [sidebarRatio, setSidebarRatio] = useState(defaultSidebarRatio)
const [sidebarRatio, setSidebarRatio] = useState(
initialLayout?.widthRatio ?? defaultSidebarRatio
)
const [isResizing, setIsResizing] = useState(false)
const sidebarRef = useRef<HTMLElement>(null)
const wasOpen = useRef(false)
@@ -249,12 +379,41 @@ export function RightAssistantSidebar({
'attention' | 'active' | 'paused' | 'finished'
>('active')
const [actionError, setActionError] = useState('')
const [terminalSessionIds, setTerminalSessionIds] = useState<
Record<string, string>
>({})
const [terminalSnapshots, setTerminalSnapshots] = useState<
Record<string, TerminalSnapshot>
>({})
const [terminalCloseConfirmation, setTerminalCloseConfirmation] =
useState<{
instance: WorkbarTabInstance
resolve: (accepted: boolean) => void
closing: boolean
error?: string
}>()
const terminalCloseCancelRef = useRef<HTMLButtonElement>(null)
const lastExternalTabRef = useRef(tab)
const artifactPreview =
artifacts.find((artifact) => artifact.id === selectedArtifactId)
const currentWorkspacePreview =
workspacePreview?.projectId === workspaceProjectId
? workspacePreview
: undefined
const terminalAdapter = useMemo<TerminalAdapter>(
() => ({
create: (request) => window.goodbuddy.terminal.create(request),
write: (request) => window.goodbuddy.terminal.write(request),
resize: (request) => window.goodbuddy.terminal.resize(request),
close: (request) => window.goodbuddy.terminal.close(request),
getSnapshot: (request) =>
window.goodbuddy.terminal.getSnapshot(request),
ack: (request) => window.goodbuddy.terminal.ack(request),
subscribe: (listener) =>
window.goodbuddy.terminal.onEvent(listener)
}),
[]
)
const sidebarWidthLimits = getSidebarWidthLimits(splitLayoutWidth)
const canResize =
open &&
@@ -288,6 +447,209 @@ export function RightAssistantSidebar({
[taskFilter, topLevelTasks]
)
useEffect(() => {
if (lastExternalTabRef.current === tab) {
return
}
lastExternalTabRef.current = tab
const requested = workbarInstances.find(
(instance) => instance.appId === tab
)
if (requested) {
// This effect intentionally mirrors the legacy external tab prop into
// the dynamic workbar only when that prop actually changes.
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveWorkbarInstanceId(requested.id)
}
}, [tab, workbarInstances])
useEffect(() => {
persistWorkbarLayout(
workbarInstances,
activeWorkbarInstanceId,
open,
sidebarRatio
)
}, [
activeWorkbarInstanceId,
open,
sidebarRatio,
workbarInstances
])
useEffect(() => {
if (terminalCloseConfirmation && !terminalCloseConfirmation.closing) {
terminalCloseCancelRef.current?.focus()
}
}, [terminalCloseConfirmation])
const resolveTerminalTarget = useCallback(
(): WorkbarTargetRef =>
currentProject
? { type: 'project', projectId: currentProject.id }
: { type: 'local' },
[currentProject]
)
const updateActiveWorkbarInstance = useCallback(
(instanceId: string): void => {
setActiveWorkbarInstanceId(instanceId)
const instance = workbarInstances.find(
(candidate) => candidate.id === instanceId
)
if (
instance &&
instance.appId !== 'terminal'
) {
onTabChange(instance.appId)
}
},
[onTabChange, workbarInstances]
)
const createWorkbarInstance = useCallback(
(request: WorkbarInstanceCreateRequest): void => {
const definition = localizedAppDefinitions.find(
(candidate) => candidate.id === request.appId
)
if (!definition) {
return
}
const sameTargetTerminals = workbarInstances.filter(
(instance) =>
instance.appId === 'terminal' &&
JSON.stringify(instance.targetRef) ===
JSON.stringify(request.targetRef)
).length
const instance: WorkbarTabInstance = {
id: crypto.randomUUID(),
appId: request.appId,
title:
request.appId === 'terminal'
? `${definition.label} ${sameTargetTerminals + 1}`
: definition.label,
...(request.targetRef
? { targetRef: request.targetRef }
: {})
}
setWorkbarInstances((current) => {
const afterIndex = request.insertAfterInstanceId
? current.findIndex(
(candidate) =>
candidate.id === request.insertAfterInstanceId
)
: -1
const next = [...current]
next.splice(afterIndex >= 0 ? afterIndex + 1 : next.length, 0, instance)
return next
})
setActiveWorkbarInstanceId(instance.id)
},
[localizedAppDefinitions, workbarInstances]
)
const removeWorkbarInstance = useCallback(
(instanceId: string): void => {
setWorkbarInstances((current) =>
current.filter((instance) => instance.id !== instanceId)
)
setTerminalSessionIds((current) => {
const next = { ...current }
delete next[instanceId]
return next
})
setTerminalSnapshots((current) => {
const next = { ...current }
delete next[instanceId]
return next
})
},
[]
)
const requestCloseWorkbarInstance = useCallback(
async (instance: WorkbarTabInstance): Promise<boolean> => {
if (instance.appId !== 'terminal') {
removeWorkbarInstance(instance.id)
return true
}
const sessionId = terminalSessionIds[instance.id]
const snapshot = terminalSnapshots[instance.id]
if (
sessionId &&
(!snapshot ||
snapshot.state === 'starting' ||
snapshot.state === 'running' ||
snapshot.state === 'closing')
) {
return new Promise<boolean>((resolve) => {
setTerminalCloseConfirmation({
instance,
resolve,
closing: false
})
})
}
if (sessionId) {
try {
await terminalAdapter.close({ sessionId })
} catch {
// An already-ended session can still be removed from the workbar.
}
}
removeWorkbarInstance(instance.id)
return true
},
[
removeWorkbarInstance,
terminalAdapter,
terminalSessionIds,
terminalSnapshots
]
)
const confirmTerminalClose = useCallback(async (): Promise<void> => {
const confirmation = terminalCloseConfirmation
if (!confirmation || confirmation.closing) {
return
}
const sessionId = terminalSessionIds[confirmation.instance.id]
setTerminalCloseConfirmation({
...confirmation,
closing: true,
error: undefined
})
try {
if (sessionId) {
await terminalAdapter.close({ sessionId })
}
removeWorkbarInstance(confirmation.instance.id)
setTerminalCloseConfirmation(undefined)
confirmation.resolve(true)
} catch (reason) {
setTerminalCloseConfirmation({
...confirmation,
closing: false,
error:
reason instanceof Error
? reason.message
: t('sidebar.terminal.closeDialog.error')
})
}
}, [
removeWorkbarInstance,
terminalAdapter,
terminalCloseConfirmation,
terminalSessionIds,
t
])
const cancelTerminalClose = useCallback((): void => {
const confirmation = terminalCloseConfirmation
setTerminalCloseConfirmation(undefined)
confirmation?.resolve(false)
}, [terminalCloseConfirmation])
useEffect(() => {
const sidebar = sidebarRef.current
const workspace = findWorkspaceSibling(sidebar)
@@ -470,36 +832,6 @@ export function RightAssistantSidebar({
})
}
const moveTabFocus = (
event: React.KeyboardEvent<HTMLButtonElement>,
tabId: AssistantSidebarTab
): void => {
const index = tabs.findIndex((item) => item.id === tabId)
const targetIndex =
event.key === 'Home'
? 0
: event.key === 'End'
? tabs.length - 1
: event.key === 'ArrowLeft'
? (index - 1 + tabs.length) % tabs.length
: event.key === 'ArrowRight'
? (index + 1) % tabs.length
: -1
if (targetIndex < 0) {
return
}
event.preventDefault()
const target = tabs[targetIndex]
if (!target) {
return
}
setActionError('')
onTabChange(target.id)
requestAnimationFrame(() => {
document.getElementById(`assistant-sidebar-tab-${target.id}`)?.focus()
})
}
return (
<aside
ref={sidebarRef}
@@ -573,57 +905,75 @@ export function RightAssistantSidebar({
role="separator"
tabIndex={canResize ? 0 : -1}
/>
<nav
aria-label={t('sidebar.categoriesAriaLabel')}
className="assistant-sidebar__tabs"
role="tablist"
>
{tabs.map((item) => (
<button
aria-controls="assistant-sidebar-panel"
aria-selected={tab === item.id}
className={
tab === item.id
? 'assistant-sidebar__tab assistant-sidebar__tab--active'
: 'assistant-sidebar__tab'
}
id={`assistant-sidebar-tab-${item.id}`}
key={item.id}
onClick={() => {
setActionError('')
onTabChange(item.id)
}}
onKeyDown={(event) => moveTabFocus(event, item.id)}
role="tab"
tabIndex={tab === item.id ? 0 : -1}
title={item.description}
type="button"
>
{item.label}
{item.id === 'tasks' && approvals.length > 0 && (
<span
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approvals.length}`}
className="assistant-sidebar__badge"
>
{approvals.length}
</span>
)}
</button>
))}
</nav>
<div
aria-labelledby={`assistant-sidebar-tab-${tab}`}
className="assistant-sidebar__body"
id="assistant-sidebar-panel"
role="tabpanel"
>
<WorkbarShell
activeInstanceId={activeWorkbarInstanceId}
appDefinitions={localizedAppDefinitions}
instances={workbarInstances}
onActiveInstanceChange={updateActiveWorkbarInstance}
onCloseInstance={requestCloseWorkbarInstance}
onCreateInstance={createWorkbarInstance}
onResolveTerminalTarget={resolveTerminalTarget}
renderTabAdornment={(instance) =>
instance.appId === 'tasks' && approvals.length > 0 ? (
<span
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approvals.length}`}
className="assistant-sidebar__badge"
>
{approvals.length}
</span>
) : null
}
renderPanel={(instance) => (
<div className="assistant-sidebar__body">
{actionError ? (
<p className="settings-error" role="alert">
{actionError}
</p>
) : null}
{tab === 'tasks' && (
{instance.appId === 'terminal' && instance.targetRef ? (
<Suspense
fallback={
<section
aria-busy="true"
aria-label={t('sidebar.terminal.loading')}
className="terminal-panel terminal-panel--loading"
>
<span>{t('sidebar.terminal.loading')}</span>
</section>
}
>
<TerminalPanel
adapter={terminalAdapter}
onRename={(title) =>
setWorkbarInstances((current) =>
current.map((candidate) =>
candidate.id === instance.id
? { ...candidate, title }
: candidate
)
)
}
onSessionChange={(snapshot) => {
setTerminalSessionIds((current) =>
current[instance.id] === snapshot.sessionId
? current
: {
...current,
[instance.id]: snapshot.sessionId
}
)
setTerminalSnapshots((current) => ({
...current,
[instance.id]: snapshot
}))
}}
sessionId={terminalSessionIds[instance.id]}
target={instance.targetRef}
title={instance.title}
/>
</Suspense>
) : null}
{instance.appId === 'tasks' && (
<section className="assistant-sidebar__section">
<h3>
<ShieldAlert size={15} />
@@ -812,7 +1162,7 @@ export function RightAssistantSidebar({
</section>
)}
{tab === 'workspace' && (
{instance.appId === 'workspace' && (
currentWorkspacePreview ? (
<section
aria-busy={currentWorkspacePreview.state === 'loading'}
@@ -934,7 +1284,7 @@ export function RightAssistantSidebar({
)
)}
{tab === 'results' && (
{instance.appId === 'results' && (
artifactPreview ? (
<section className="assistant-sidebar__preview">
<header>
@@ -1043,7 +1393,7 @@ export function RightAssistantSidebar({
)
)}
{tab === 'browser' && (
{instance.appId === 'browser' && (
<section className="assistant-sidebar__browser">
<header>
<span>
@@ -1149,7 +1499,54 @@ export function RightAssistantSidebar({
)}
</section>
)}
</div>
</div>
)}
/>
{terminalCloseConfirmation ? (
<div className="assistant-sidebar__overlay">
<section
aria-label={t('sidebar.terminal.closeDialog.ariaLabel')}
className="assistant-sidebar__terminal-close"
onKeyDown={(event) => {
if (
event.key === 'Escape' &&
!terminalCloseConfirmation.closing
) {
event.preventDefault()
cancelTerminalClose()
}
}}
role="alertdialog"
>
<h2>{t('sidebar.terminal.closeDialog.title')}</h2>
<p>{t('sidebar.terminal.closeDialog.description')}</p>
{terminalCloseConfirmation.error ? (
<p role="alert">{terminalCloseConfirmation.error}</p>
) : null}
<div>
<button
className="secondary-button"
disabled={terminalCloseConfirmation.closing}
onClick={cancelTerminalClose}
ref={terminalCloseCancelRef}
type="button"
>
{t('sidebar.terminal.closeDialog.cancel')}
</button>
<button
className="danger-button"
disabled={terminalCloseConfirmation.closing}
onClick={() => void confirmTerminalClose()}
type="button"
>
{terminalCloseConfirmation.closing
? t('sidebar.terminal.closeDialog.closing')
: t('sidebar.terminal.closeDialog.confirm')}
</button>
</div>
</section>
</div>
) : null}
</aside>
)
}
+458
View File
@@ -0,0 +1,458 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
TerminalEvent,
TerminalSnapshot
} from '../../shared/terminal-contracts'
import {
TerminalPanel,
resolveTerminalFontFamily,
type TerminalAdapter,
type TerminalEmulator,
type TerminalFactory
} from './TerminalPanel'
vi.mock('@xterm/xterm', () => ({ Terminal: class MockTerminal {} }))
vi.mock('@xterm/addon-fit', () => ({ FitAddon: class MockFitAddon {} }))
vi.mock('@xterm/addon-search', () => ({
SearchAddon: class MockSearchAddon {}
}))
const sessionId = '00000000-0000-4000-8000-000000000201'
function snapshot(
overrides: Partial<TerminalSnapshot> = {}
): TerminalSnapshot {
return {
sessionId,
target: { type: 'local' },
targetLabel: '本机',
title: '终端 · 本机 1',
state: 'running',
shell: 'PowerShell 7',
workingDirectory: 'D:\\workspace',
size: { cols: 80, rows: 24 },
lastSequence: 0,
exit: null,
error: null,
...overrides
}
}
type EmulatorHarness = {
factory: TerminalFactory
terminal: TerminalEmulator
emitInput: (data: string) => void
write: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
fit: ReturnType<typeof vi.fn>
}
function emulatorHarness(): EmulatorHarness {
let inputListener: ((data: string) => void) | undefined
const write = vi.fn((_: string, callback?: () => void) => callback?.())
const dispose = vi.fn()
const fit = vi.fn()
const terminal: TerminalEmulator = {
cols: 80,
rows: 24,
open: vi.fn(),
onData: vi.fn((listener: (data: string) => void) => {
inputListener = listener
return { dispose: vi.fn() }
}),
write,
clear: vi.fn(),
selectAll: vi.fn(),
getSelection: vi.fn(() => 'selected output'),
focus: vi.fn(),
dispose
}
return {
terminal,
write,
dispose,
fit,
emitInput: (data) => inputListener?.(data),
factory: () => ({
terminal,
fitAddon: { fit },
searchAddon: { findNext: vi.fn(() => true) }
})
}
}
type AdapterHarness = {
adapter: TerminalAdapter
emit: (event: TerminalEvent) => void
}
function adapterHarness(
createResult: TerminalSnapshot = snapshot()
): AdapterHarness {
let eventListener: ((event: TerminalEvent) => void) | undefined
const adapter: TerminalAdapter = {
create: vi.fn(async () => createResult),
write: vi.fn(async () => undefined),
resize: vi.fn(async () => undefined),
close: vi.fn(async () =>
snapshot({ state: 'exited', exit: { exitCode: 0, signal: null } })
),
getSnapshot: vi.fn(async () => createResult),
ack: vi.fn(async () => undefined),
subscribe: vi.fn((listener) => {
eventListener = listener
return vi.fn()
})
}
return {
adapter,
emit: (event) => eventListener?.(event)
}
}
type ResizeObserverHarness = {
trigger: (width?: number, height?: number) => void
disconnect: ReturnType<typeof vi.fn>
}
let resizeObserver: ResizeObserverHarness
beforeEach(() => {
const disconnect = vi.fn()
class MockResizeObserver {
private readonly callback: ResizeObserverCallback
constructor(callback: ResizeObserverCallback) {
this.callback = callback
resizeObserver = {
disconnect,
trigger: (width = 800, height = 480) =>
this.callback(
[
{
contentRect: {
width,
height
} as DOMRectReadOnly
} as ResizeObserverEntry
],
this as unknown as ResizeObserver
)
}
}
observe(): void {}
unobserve(): void {}
disconnect = disconnect
}
vi.stubGlobal('ResizeObserver', MockResizeObserver)
})
afterEach(() => {
cleanup()
document.documentElement.style.removeProperty('--font-family-mono')
vi.unstubAllGlobals()
})
describe('TerminalPanel', () => {
it('creates only after the first valid fitted size and forwards input', async () => {
const backend = adapterHarness()
const emulator = emulatorHarness()
render(
<TerminalPanel
adapter={backend.adapter}
target={{ type: 'local' }}
terminalFactory={emulator.factory}
/>
)
resizeObserver.trigger(0, 0)
expect(backend.adapter.create).not.toHaveBeenCalled()
resizeObserver.trigger()
await waitFor(() =>
expect(backend.adapter.create).toHaveBeenCalledWith({
target: { type: 'local' },
cols: 80,
rows: 24
})
)
expect(screen.getByText('PowerShell 7')).toBeInTheDocument()
emulator.emitInput('Get-Location\r')
await waitFor(() =>
expect(backend.adapter.write).toHaveBeenCalledWith({
sessionId,
data: 'Get-Location\r'
})
)
})
it('deduplicates resize, consumes ordered events, and ACKs output', async () => {
const backend = adapterHarness()
const emulator = emulatorHarness()
render(
<TerminalPanel
adapter={backend.adapter}
target={{ type: 'local' }}
terminalFactory={emulator.factory}
/>
)
resizeObserver.trigger()
await waitFor(() =>
expect(backend.adapter.create).toHaveBeenCalledOnce()
)
resizeObserver.trigger(700, 400)
resizeObserver.trigger(600, 300)
expect(backend.adapter.resize).not.toHaveBeenCalled()
backend.emit({
sessionId,
sequence: 1,
type: 'output',
data: 'hello'
})
backend.emit({
sessionId,
sequence: 1,
type: 'output',
data: 'duplicate'
})
await waitFor(() =>
expect(emulator.write).toHaveBeenCalledWith(
'hello',
expect.any(Function)
)
)
expect(emulator.write).not.toHaveBeenCalledWith(
'duplicate',
expect.anything()
)
Object.defineProperty(emulator.terminal, 'cols', { value: 100 })
Object.defineProperty(emulator.terminal, 'rows', { value: 30 })
resizeObserver.trigger()
resizeObserver.trigger()
await waitFor(() =>
expect(backend.adapter.resize).toHaveBeenCalledOnce()
)
expect(backend.adapter.resize).toHaveBeenCalledWith({
sessionId,
cols: 100,
rows: 30
})
backend.emit({
sessionId,
sequence: 2,
type: 'state',
state: 'interrupted'
})
await waitFor(() =>
expect(backend.adapter.ack).toHaveBeenLastCalledWith({
sessionId,
sequence: 2
})
)
expect(screen.getAllByText('连接已中断')).not.toHaveLength(0)
})
it('unsubscribes and destroys the emulator without closing the session', async () => {
const backend = adapterHarness()
const emulator = emulatorHarness()
const unsubscribe = vi.fn()
vi.mocked(backend.adapter.subscribe).mockImplementation((listener) => {
void listener
return unsubscribe
})
const view = render(
<TerminalPanel
adapter={backend.adapter}
sessionId={sessionId}
target={{ type: 'local' }}
terminalFactory={emulator.factory}
/>
)
await waitFor(() =>
expect(backend.adapter.getSnapshot).toHaveBeenCalledWith({
sessionId
})
)
view.unmount()
expect(unsubscribe).toHaveBeenCalledOnce()
expect(emulator.dispose).toHaveBeenCalledOnce()
expect(resizeObserver.disconnect).toHaveBeenCalledOnce()
expect(backend.adapter.close).not.toHaveBeenCalled()
})
it('leaves session closing to the workbar tab action', async () => {
const backend = adapterHarness()
const emulator = emulatorHarness()
render(
<TerminalPanel
adapter={backend.adapter}
sessionId={sessionId}
target={{ type: 'local' }}
terminalFactory={emulator.factory}
/>
)
await screen.findByText('PowerShell 7')
expect(
screen.queryByRole('button', { name: '关闭终端会话' })
).not.toBeInTheDocument()
expect(backend.adapter.close).not.toHaveBeenCalled()
})
it('renders and ACKs startup events included in the create snapshot sequence', async () => {
const backend = adapterHarness(snapshot({ lastSequence: 2 }))
const emulator = emulatorHarness()
render(
<TerminalPanel
adapter={backend.adapter}
target={{ type: 'local' }}
terminalFactory={emulator.factory}
/>
)
resizeObserver.trigger()
await waitFor(() =>
expect(backend.adapter.create).toHaveBeenCalledOnce()
)
backend.emit({
sessionId,
sequence: 1,
type: 'output',
data: 'startup prompt'
})
backend.emit({
sessionId,
sequence: 2,
type: 'state',
state: 'running'
})
await waitFor(() =>
expect(emulator.write).toHaveBeenCalledWith(
'startup prompt',
expect.any(Function)
)
)
expect(backend.adapter.ack).toHaveBeenLastCalledWith({
sessionId,
sequence: 2
})
})
it('keeps one emulator when callback identities change', async () => {
const backend = adapterHarness()
const emulator = emulatorHarness()
const factory = vi.fn(emulator.factory)
const firstCallback = vi.fn()
const view = render(
<TerminalPanel
adapter={backend.adapter}
onSessionChange={firstCallback}
target={{ type: 'local' }}
terminalFactory={factory}
/>
)
resizeObserver.trigger()
await waitFor(() =>
expect(backend.adapter.create).toHaveBeenCalledOnce()
)
const latestCallback = vi.fn()
view.rerender(
<TerminalPanel
adapter={backend.adapter}
onSessionChange={latestCallback}
target={{ type: 'local' }}
terminalFactory={factory}
/>
)
backend.emit({
sessionId,
sequence: 1,
type: 'state',
state: 'interrupted'
})
await waitFor(() =>
expect(latestCallback).toHaveBeenCalledWith(
expect.objectContaining({ state: 'interrupted' })
)
)
expect(factory).toHaveBeenCalledOnce()
expect(emulator.dispose).not.toHaveBeenCalled()
})
it('closes a session whose create result arrives after unmount', async () => {
let resolveCreate:
| ((value: TerminalSnapshot) => void)
| undefined
const backend = adapterHarness()
vi.mocked(backend.adapter.create).mockImplementation(
() =>
new Promise((resolve) => {
resolveCreate = resolve
})
)
const emulator = emulatorHarness()
const view = render(
<TerminalPanel
adapter={backend.adapter}
target={{ type: 'local' }}
terminalFactory={emulator.factory}
/>
)
resizeObserver.trigger()
await waitFor(() =>
expect(backend.adapter.create).toHaveBeenCalledOnce()
)
view.unmount()
resolveCreate?.(snapshot())
await waitFor(() =>
expect(backend.adapter.close).toHaveBeenCalledWith({ sessionId })
)
})
it('uses the resolved cross-platform monospace font stack', () => {
document.documentElement.style.setProperty(
'--font-family-mono',
'"Test Mono", monospace'
)
expect(resolveTerminalFontFamily()).toBe(
'"Test Mono", monospace'
)
})
it('keeps creation failures visible and allows retrying', async () => {
const backend = adapterHarness()
vi.mocked(backend.adapter.create)
.mockRejectedValueOnce(new Error('Shell 启动失败'))
.mockResolvedValueOnce(snapshot())
const emulator = emulatorHarness()
render(
<TerminalPanel
adapter={backend.adapter}
target={{ type: 'local' }}
terminalFactory={emulator.factory}
/>
)
resizeObserver.trigger()
expect(await screen.findByRole('alert')).toHaveTextContent(
'Shell 启动失败'
)
expect(screen.getAllByText('连接失败')).not.toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() =>
expect(backend.adapter.create).toHaveBeenCalledTimes(2)
)
expect(await screen.findAllByText('已连接')).not.toHaveLength(0)
})
})
+786
View File
@@ -0,0 +1,786 @@
import { SearchAddon } from '@xterm/addon-search'
import { FitAddon } from '@xterm/addon-fit'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import {
Clipboard,
Copy,
Eraser,
Pencil,
RefreshCw,
Search
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState
} from 'react'
import type { DesktopApi } from '../../shared/contracts'
import type {
TerminalEvent,
TerminalSessionId,
TerminalSnapshot,
TerminalTarget
} from '../../shared/terminal-contracts'
import './terminal-panel.css'
export type TerminalAdapter = Omit<DesktopApi['terminal'], 'onEvent'> & {
subscribe: DesktopApi['terminal']['onEvent']
}
type Disposable = { dispose: () => void }
export type TerminalEmulator = {
readonly cols: number
readonly rows: number
open: (element: HTMLElement) => void
onData: (listener: (data: string) => void) => Disposable
write: (data: string, callback?: () => void) => void
clear: () => void
selectAll: () => void
getSelection: () => string
focus: () => void
dispose: () => void
}
export type TerminalFitAddon = {
fit: () => void
}
export type TerminalSearchAddon = {
findNext: (term: string) => boolean
}
export type TerminalFactoryResult = {
terminal: TerminalEmulator
fitAddon: TerminalFitAddon
searchAddon: TerminalSearchAddon
activateRenderer?: () => void
}
export type TerminalFactory = () => TerminalFactoryResult
export type TerminalPanelProps = {
adapter: TerminalAdapter
target: TerminalTarget
sessionId?: TerminalSessionId
title?: string
terminalFactory?: TerminalFactory
onSessionChange?: (snapshot: TerminalSnapshot) => void
onRename?: (title: string) => void
}
const announcedStates = new Set<TerminalSnapshot['state']>([
'running',
'exited',
'interrupted',
'failed'
])
const fallbackTerminalFontFamily = [
'"Cascadia Mono"',
'"Cascadia Code"',
'"SFMono-Regular"',
'Menlo',
'Monaco',
'Consolas',
'"Noto Sans Mono"',
'"DejaVu Sans Mono"',
'"Liberation Mono"',
'monospace'
].join(', ')
export function resolveTerminalFontFamily(): string {
const configured = getComputedStyle(document.documentElement)
.getPropertyValue('--font-family-mono')
.trim()
return configured || fallbackTerminalFontFamily
}
function createDefaultTerminal(): TerminalFactoryResult {
const terminal = new Terminal({
cursorBlink: true,
scrollback: 10_000,
fontFamily: resolveTerminalFontFamily(),
fontSize: 13,
rescaleOverlappingGlyphs: true
})
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(searchAddon)
return {
terminal,
fitAddon,
searchAddon,
activateRenderer: () => {
const webglAddon = new WebglAddon()
const contextLossSubscription = webglAddon.onContextLoss(() => {
contextLossSubscription.dispose()
webglAddon.dispose()
})
try {
terminal.loadAddon(webglAddon)
} catch {
contextLossSubscription.dispose()
webglAddon.dispose()
}
}
}
}
function failureMessage(reason: unknown, fallback: string): string {
return reason instanceof Error && reason.message
? reason.message
: fallback
}
function isValidSize(
cols: number,
rows: number
): boolean {
return cols >= 2 && rows >= 1
}
export function TerminalPanel({
adapter,
target,
sessionId,
title,
terminalFactory = createDefaultTerminal,
onSessionChange,
onRename
}: TerminalPanelProps): React.JSX.Element {
const { t } = useTranslation('workspace')
const searchInputId = useId()
const titleInputId = useId()
const hostRef = useRef<HTMLDivElement>(null)
const emulatorRef = useRef<TerminalEmulator | undefined>(undefined)
const fitAddonRef = useRef<TerminalFitAddon | undefined>(undefined)
const searchAddonRef = useRef<TerminalSearchAddon | undefined>(
undefined
)
const activeSessionRef = useRef<TerminalSessionId | undefined>(
undefined
)
const snapshotRef = useRef<TerminalSnapshot | undefined>(undefined)
const mountedRef = useRef(false)
const acceptedSequenceRef = useRef(0)
const eventQueueRef = useRef(Promise.resolve())
const lastSizeRef = useRef<
{ cols: number; rows: number } | undefined
>(undefined)
const operationGenerationRef = useRef(0)
const onSessionChangeRef = useRef(onSessionChange)
const onRenameRef = useRef(onRename)
const translationRef = useRef(t)
const [snapshot, setSnapshot] = useState<
TerminalSnapshot | undefined
>(undefined)
const [error, setError] = useState<string | undefined>(undefined)
const [announcement, setAnnouncement] = useState('')
const [searchOpen, setSearchOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [renaming, setRenaming] = useState(false)
const [draftTitle, setDraftTitle] = useState(title ?? '')
const [hasMeasuredSize, setHasMeasuredSize] = useState(false)
useLayoutEffect(() => {
onSessionChangeRef.current = onSessionChange
onRenameRef.current = onRename
translationRef.current = t
}, [onRename, onSessionChange, t])
const publishSnapshot = useCallback((next: TerminalSnapshot): void => {
snapshotRef.current = next
setSnapshot(next)
onSessionChangeRef.current?.(next)
if (announcedStates.has(next.state)) {
setAnnouncement(
translationRef.current(
`sidebar.terminal.states.${next.state}`
)
)
}
}, [])
const consumeEvent = useCallback(
async (event: TerminalEvent): Promise<void> => {
if (
!mountedRef.current ||
event.sessionId !== activeSessionRef.current ||
event.sequence <= acceptedSequenceRef.current
) {
return
}
acceptedSequenceRef.current = event.sequence
if (event.type === 'output') {
await new Promise<void>((resolve) => {
emulatorRef.current?.write(event.data, resolve)
if (!emulatorRef.current) {
resolve()
}
})
} else {
const current = snapshotRef.current
if (current) {
if (event.type === 'state') {
publishSnapshot({ ...current, state: event.state })
} else if (event.type === 'exit') {
publishSnapshot({
...current,
state: 'exited',
exit: event.exit,
error: null
})
} else {
publishSnapshot({
...current,
state: 'failed',
error: event.error,
exit: null
})
setError(event.error.message)
}
}
}
if (mountedRef.current) {
try {
await adapter.ack({
sessionId: event.sessionId,
sequence: event.sequence
})
} catch (reason) {
if (mountedRef.current) {
setError(
failureMessage(
reason,
translationRef.current('sidebar.terminal.errors.ack')
)
)
}
}
}
},
[adapter, publishSnapshot]
)
useEffect(() => {
mountedRef.current = true
const {
terminal,
fitAddon,
searchAddon,
activateRenderer
} = terminalFactory()
emulatorRef.current = terminal
fitAddonRef.current = fitAddon
searchAddonRef.current = searchAddon
if (hostRef.current) {
terminal.open(hostRef.current)
activateRenderer?.()
}
const inputSubscription = terminal.onData((data) => {
const current = snapshotRef.current
if (!current || current.state !== 'running') {
return
}
void adapter
.write({ sessionId: current.sessionId, data })
.catch((reason: unknown) => {
if (mountedRef.current) {
setError(
failureMessage(
reason,
translationRef.current('sidebar.terminal.errors.write')
)
)
}
})
})
const unsubscribe = adapter.subscribe((event) => {
eventQueueRef.current = eventQueueRef.current.then(
() => consumeEvent(event),
() => consumeEvent(event)
)
})
return () => {
mountedRef.current = false
operationGenerationRef.current += 1
unsubscribe()
inputSubscription.dispose()
terminal.dispose()
emulatorRef.current = undefined
fitAddonRef.current = undefined
searchAddonRef.current = undefined
}
}, [adapter, consumeEvent, terminalFactory])
const attachSession = useCallback(
async (
id: TerminalSessionId,
generation: number
): Promise<void> => {
try {
const next = await adapter.getSnapshot({ sessionId: id })
if (
!mountedRef.current ||
operationGenerationRef.current !== generation
) {
return
}
activeSessionRef.current = next.sessionId
acceptedSequenceRef.current = next.lastSequence
publishSnapshot(next)
setError(next.error?.message)
emulatorRef.current?.focus()
} catch (reason) {
if (
mountedRef.current &&
operationGenerationRef.current === generation
) {
setError(
failureMessage(
reason,
translationRef.current('sidebar.terminal.errors.snapshot')
)
)
setAnnouncement(
translationRef.current('sidebar.terminal.states.failed')
)
}
}
},
[adapter, publishSnapshot]
)
useEffect(() => {
if (!sessionId) {
return
}
const generation = ++operationGenerationRef.current
void attachSession(sessionId, generation)
}, [attachSession, sessionId])
const createSession = useCallback(
async (cols: number, rows: number): Promise<void> => {
const generation = ++operationGenerationRef.current
setError(undefined)
try {
const next = await adapter.create({ target, cols, rows })
if (
!mountedRef.current ||
operationGenerationRef.current !== generation
) {
try {
await adapter.close({ sessionId: next.sessionId })
} catch {
// The owning tab is gone; Main shutdown cleanup remains the fallback.
}
return
}
activeSessionRef.current = next.sessionId
acceptedSequenceRef.current = 0
publishSnapshot(next)
setError(next.error?.message)
emulatorRef.current?.focus()
} catch (reason) {
if (
mountedRef.current &&
operationGenerationRef.current === generation
) {
setError(
failureMessage(
reason,
translationRef.current('sidebar.terminal.errors.create')
)
)
setAnnouncement(
translationRef.current('sidebar.terminal.states.failed')
)
}
}
},
[adapter, publishSnapshot, target]
)
useEffect(() => {
const host = hostRef.current
if (!host) {
return
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (
!entry ||
entry.contentRect.width <= 0 ||
entry.contentRect.height <= 0
) {
return
}
fitAddonRef.current?.fit()
const emulator = emulatorRef.current
if (
!emulator ||
!isValidSize(emulator.cols, emulator.rows)
) {
return
}
const size = { cols: emulator.cols, rows: emulator.rows }
if (
lastSizeRef.current?.cols === size.cols &&
lastSizeRef.current.rows === size.rows
) {
return
}
lastSizeRef.current = size
setHasMeasuredSize(true)
const current = snapshotRef.current
if (!current && !sessionId) {
void createSession(size.cols, size.rows)
return
}
if (current && current.state === 'running') {
void adapter
.resize({ sessionId: current.sessionId, ...size })
.catch((reason: unknown) => {
if (mountedRef.current) {
setError(
failureMessage(
reason,
translationRef.current(
'sidebar.terminal.errors.resize'
)
)
)
}
})
}
})
observer.observe(host)
return () => observer.disconnect()
}, [adapter, createSession, sessionId])
const runSearch = (): void => {
if (searchTerm) {
searchAddonRef.current?.findNext(searchTerm)
emulatorRef.current?.focus()
}
}
const copySelection = async (): Promise<void> => {
const value = emulatorRef.current?.getSelection() ?? ''
if (!value) {
return
}
try {
await navigator.clipboard.writeText(value)
} catch (reason) {
setError(
failureMessage(
reason,
translationRef.current('sidebar.terminal.errors.copy')
)
)
}
}
const paste = async (): Promise<void> => {
const current = snapshotRef.current
if (!current || current.state !== 'running') {
return
}
try {
const data = await navigator.clipboard.readText()
if (data) {
await adapter.write({ sessionId: current.sessionId, data })
}
emulatorRef.current?.focus()
} catch (reason) {
setError(
failureMessage(
reason,
translationRef.current('sidebar.terminal.errors.paste')
)
)
}
}
const reopenSession = async (): Promise<void> => {
const size = lastSizeRef.current
if (!size) {
return
}
const current = snapshotRef.current
if (current) {
try {
await adapter.close({ sessionId: current.sessionId })
} catch (reason) {
if (mountedRef.current) {
setError(
failureMessage(
reason,
translationRef.current(
'sidebar.terminal.errors.closePrevious'
)
)
)
}
return
}
}
if (!mountedRef.current) {
return
}
emulatorRef.current?.clear()
activeSessionRef.current = undefined
acceptedSequenceRef.current = 0
snapshotRef.current = undefined
setSnapshot(undefined)
setError(undefined)
await createSession(size.cols, size.rows)
}
const submitRename = (): void => {
const nextTitle = draftTitle.trim()
if (!nextTitle) {
return
}
onRenameRef.current?.(nextTitle)
setRenaming(false)
emulatorRef.current?.focus()
}
const canReopen =
snapshot?.state === 'exited' ||
snapshot?.state === 'interrupted' ||
snapshot?.state === 'failed'
const location =
snapshot?.targetLabel ??
(target.type === 'local'
? t('sidebar.terminal.location.local')
: t('sidebar.terminal.location.target'))
return (
<section
className="terminal-panel"
aria-label={t('sidebar.terminal.ariaLabel', {
title: title ?? location
})}
>
<div
className="terminal-panel__toolbar"
role="toolbar"
aria-label={t('sidebar.terminal.toolbar.ariaLabel')}
>
<button
type="button"
title={t('sidebar.terminal.toolbar.search')}
aria-label={t('sidebar.terminal.toolbar.search')}
aria-pressed={searchOpen}
onClick={() => setSearchOpen((value) => !value)}
>
<Search aria-hidden="true" />
</button>
<button
type="button"
title={t('sidebar.terminal.toolbar.copy')}
aria-label={t('sidebar.terminal.toolbar.copy')}
onClick={() => void copySelection()}
>
<Copy aria-hidden="true" />
</button>
<button
type="button"
title={t('sidebar.terminal.toolbar.paste')}
aria-label={t('sidebar.terminal.toolbar.paste')}
disabled={snapshot?.state !== 'running'}
onClick={() => void paste()}
>
<Clipboard aria-hidden="true" />
</button>
<button
type="button"
title={t('sidebar.terminal.toolbar.selectAll')}
aria-label={t('sidebar.terminal.toolbar.selectAllAriaLabel')}
onClick={() => emulatorRef.current?.selectAll()}
>
{t('sidebar.terminal.toolbar.selectAll')}
</button>
<button
type="button"
title={t('sidebar.terminal.toolbar.clear')}
aria-label={t('sidebar.terminal.toolbar.clear')}
onClick={() => {
emulatorRef.current?.clear()
emulatorRef.current?.focus()
}}
>
<Eraser aria-hidden="true" />
</button>
{onRename ? (
<button
type="button"
title={t('sidebar.terminal.toolbar.rename')}
aria-label={t('sidebar.terminal.toolbar.rename')}
aria-pressed={renaming}
onClick={() => {
setDraftTitle(title ?? snapshot?.title ?? '')
setRenaming(true)
}}
>
<Pencil aria-hidden="true" />
</button>
) : null}
<span className="terminal-panel__toolbar-spacer" />
{canReopen ? (
<button
type="button"
className="terminal-panel__reopen"
onClick={() => void reopenSession()}
>
<RefreshCw aria-hidden="true" />
{snapshot.state === 'interrupted'
? t('sidebar.terminal.reopen.interrupted')
: t('sidebar.terminal.reopen.default')}
</button>
) : null}
</div>
{searchOpen ? (
<form
className="terminal-panel__inline-form"
role="search"
onSubmit={(event) => {
event.preventDefault()
runSearch()
}}
>
<label htmlFor={searchInputId}>
{t('sidebar.terminal.search.label')}
</label>
<input
id={searchInputId}
autoFocus
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setSearchOpen(false)
emulatorRef.current?.focus()
}
}}
/>
<button type="submit">
{t('sidebar.terminal.search.next')}
</button>
</form>
) : null}
{renaming ? (
<form
className="terminal-panel__inline-form"
onSubmit={(event) => {
event.preventDefault()
submitRename()
}}
>
<label htmlFor={titleInputId}>
{t('sidebar.terminal.rename.label')}
</label>
<input
id={titleInputId}
autoFocus
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setRenaming(false)
emulatorRef.current?.focus()
}
}}
/>
<button type="submit" disabled={!draftTitle.trim()}>
{t('sidebar.terminal.rename.save')}
</button>
<button type="button" onClick={() => setRenaming(false)}>
{t('sidebar.terminal.rename.cancel')}
</button>
</form>
) : null}
{error ? (
<div className="terminal-panel__error" role="alert">
<span>{error}</span>
{!snapshot ? (
<button
type="button"
disabled={!hasMeasuredSize}
onClick={() => void reopenSession()}
>
重试
</button>
) : null}
</div>
) : null}
<div
ref={hostRef}
className="terminal-panel__emulator"
aria-label={t('sidebar.terminal.status.emulatorAriaLabel')}
/>
<footer
className="terminal-panel__status"
aria-label={t('sidebar.terminal.status.ariaLabel')}
>
<span title={location}>{location}</span>
<span
title={
snapshot?.workingDirectory ??
t('sidebar.terminal.status.readingDirectory')
}
>
{snapshot?.workingDirectory ??
t('sidebar.terminal.status.readingDirectory')}
</span>
<span
title={
snapshot?.shell ??
t('sidebar.terminal.status.readingShell')
}
>
{snapshot?.shell ??
t('sidebar.terminal.status.readingShell')}
</span>
<strong
data-state={snapshot?.state ?? (error ? 'failed' : 'starting')}
>
{snapshot
? t(`sidebar.terminal.states.${snapshot.state}`)
: error
? t('sidebar.terminal.states.failed')
: t('sidebar.terminal.states.creating')}
{snapshot?.state === 'exited' && snapshot.exit?.exitCode !== null
? t('sidebar.terminal.status.exitCode', {
code: snapshot.exit?.exitCode
})
: ''}
</strong>
</footer>
<div className="terminal-panel__announcement" aria-live="polite">
{announcement}
</div>
</section>
)
}
+240
View File
@@ -0,0 +1,240 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within
} from '@testing-library/react'
import {
useState,
type ReactNode
} from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
WorkbarTabInstance,
WorkbarTargetRef
} from '../../shared/workbar-contracts'
import {
DEFAULT_WORKBAR_INSTANCES,
WorkbarShell,
type WorkbarInstanceCreateRequest
} from './WorkbarShell'
afterEach(cleanup)
const terminalOne: WorkbarTabInstance = {
id: '20000000-0000-4000-8000-000000000001',
appId: 'terminal',
title: '终端 · 本机 1',
targetRef: { type: 'local' }
}
function ControlledShell({
initialInstances = DEFAULT_WORKBAR_INSTANCES,
onCreate = vi.fn(),
onTerminalTarget = vi.fn((): WorkbarTargetRef => ({
type: 'local'
}))
}: {
initialInstances?: readonly WorkbarTabInstance[]
onCreate?: (request: WorkbarInstanceCreateRequest) => void
onTerminalTarget?: () =>
| WorkbarTargetRef
| Promise<WorkbarTargetRef>
}): ReactNode {
const [instances, setInstances] = useState(initialInstances)
const [activeId, setActiveId] = useState(
initialInstances[0]?.id ?? null
)
return (
<WorkbarShell
activeInstanceId={activeId}
instances={instances}
onActiveInstanceChange={setActiveId}
onCloseInstance={(instance) =>
setInstances((current) =>
current.filter((candidate) => candidate.id !== instance.id)
)
}
onCreateInstance={onCreate}
onResolveTerminalTarget={onTerminalTarget}
renderPanel={(instance) => <p>{instance.title}内容</p>}
/>
)
}
describe('WorkbarShell', () => {
it('provides four default single-instance tabs and keeps add outside the tablist', () => {
render(<ControlledShell />)
const tablist = screen.getByRole('tablist', {
name: '已打开的工作栏应用'
})
expect(within(tablist).getAllByRole('tab')).toHaveLength(4)
expect(
within(tablist).queryByRole('button', {
name: '打开工作栏应用'
})
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: '打开工作栏应用' })
).toBeVisible()
expect(tablist.parentElement).toHaveClass(
'workbar-shell__tab-scroll'
)
expect(
screen.getByRole('tab', { name: '任务中心' }).parentElement
).toHaveClass('workbar-shell__tab-item--active')
expect(
screen.getByRole('tab', { name: '工作区' }).parentElement
).not.toHaveClass('workbar-shell__tab-item--active')
})
it('opens the catalog, focuses its first choice, and restores add-button focus on Escape', async () => {
render(<ControlledShell />)
const addButton = screen.getByRole('button', {
name: '打开工作栏应用'
})
fireEvent.click(addButton)
const firstChoice = screen
.getByText('任务中心', { selector: 'strong' })
.closest('button')!
await waitFor(() => expect(firstChoice).toHaveFocus())
expect(
screen.getByRole('heading', { name: '新建工作栏应用' })
).toBeInTheDocument()
fireEvent.keyDown(firstChoice, { key: 'Escape' })
await waitFor(() => expect(addButton).toHaveFocus())
expect(
screen.queryByRole('heading', { name: '新建工作栏应用' })
).not.toBeInTheDocument()
expect(
screen.getByRole('tab', { name: '任务中心' })
).toHaveAttribute('aria-selected', 'true')
})
it('focuses an existing single instance instead of requesting a duplicate', async () => {
const onCreate = vi.fn()
render(<ControlledShell onCreate={onCreate} />)
fireEvent.click(
screen.getByRole('button', { name: '打开工作栏应用' })
)
fireEvent.click(
screen
.getByText('工作区', { selector: 'strong' })
.closest('button')!
)
const workspaceTab = screen.getByRole('tab', {
name: '工作区'
})
await waitFor(() => expect(workspaceTab).toHaveFocus())
expect(workspaceTab).toHaveAttribute('aria-selected', 'true')
expect(onCreate).not.toHaveBeenCalled()
})
it('resolves the current terminal target for every multiple-instance creation', async () => {
const onCreate = vi.fn()
const onTerminalTarget = vi.fn((): WorkbarTargetRef => ({
type: 'local'
}))
render(
<ControlledShell
initialInstances={[...DEFAULT_WORKBAR_INSTANCES, terminalOne]}
onCreate={onCreate}
onTerminalTarget={onTerminalTarget}
/>
)
for (let count = 1; count <= 2; count += 1) {
fireEvent.click(
screen.getByRole('button', {
name: '打开工作栏应用'
})
)
fireEvent.click(
screen.getByRole('button', { name: /^终端/u })
)
await waitFor(() =>
expect(onCreate).toHaveBeenCalledTimes(count)
)
}
expect(onTerminalTarget).toHaveBeenCalledTimes(2)
expect(onCreate).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
appId: 'terminal',
targetRef: { type: 'local' }
})
)
})
it('supports roving Arrow, Home, and End navigation', () => {
render(<ControlledShell />)
const tasks = screen.getByRole('tab', { name: '任务中心' })
tasks.focus()
fireEvent.keyDown(tasks, { key: 'ArrowRight' })
const workspace = screen.getByRole('tab', { name: '工作区' })
expect(workspace).toHaveFocus()
expect(workspace).toHaveAttribute('tabindex', '0')
fireEvent.keyDown(workspace, { key: 'End' })
const results = screen.getByRole('tab', { name: '成果' })
expect(results).toHaveFocus()
fireEvent.keyDown(results, { key: 'Home' })
expect(tasks).toHaveFocus()
})
it('closes the active tab, activates its right neighbor, and restores focus', async () => {
render(<ControlledShell />)
const workspace = screen.getByRole('tab', { name: '工作区' })
fireEvent.click(workspace)
fireEvent.click(
screen.getByRole('button', { name: '关闭工作区' })
)
const browser = screen.getByRole('tab', { name: '浏览器' })
await waitFor(() => expect(browser).toHaveFocus())
expect(browser).toHaveAttribute('aria-selected', 'true')
expect(
screen.queryByRole('tab', { name: '工作区' })
).not.toBeInTheDocument()
})
it('keeps the current active tab focused when a background tab closes', async () => {
render(<ControlledShell />)
const tasks = screen.getByRole('tab', { name: '任务中心' })
fireEvent.click(
screen.getByRole('button', { name: '关闭成果' })
)
await waitFor(() => expect(tasks).toHaveFocus())
expect(tasks).toHaveAttribute('aria-selected', 'true')
})
it('shows the empty state while preserving the add command after the last close', async () => {
render(
<ControlledShell initialInstances={[DEFAULT_WORKBAR_INSTANCES[0]!]} />
)
fireEvent.click(
screen.getByRole('button', { name: '关闭任务中心' })
)
const addButton = screen.getByRole('button', {
name: '打开工作栏应用'
})
await waitFor(() => expect(addButton).toHaveFocus())
expect(
screen.getByText('还没有打开的工作栏应用')
).toBeInTheDocument()
expect(addButton).toBeVisible()
})
})
+555
View File
@@ -0,0 +1,555 @@
import {
ClipboardCheck,
FileOutput,
FolderTree,
Globe2,
SquareTerminal,
X,
type LucideIcon
} from 'lucide-react'
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
type ReactNode
} from 'react'
import { useTranslation } from 'react-i18next'
import {
WORKBAR_APP_DEFINITIONS,
WORKBAR_LIMITS,
type WorkbarAppDefinition,
type WorkbarAppId,
type WorkbarTabInstance,
type WorkbarTargetRef
} from '../../shared/workbar-contracts'
const WORKBAR_APP_ICONS: Record<WorkbarAppId, LucideIcon> = {
tasks: ClipboardCheck,
workspace: FolderTree,
browser: Globe2,
results: FileOutput,
terminal: SquareTerminal
}
const DEFAULT_INSTANCE_IDS = {
tasks: '10000000-0000-4000-8000-000000000001',
workspace: '10000000-0000-4000-8000-000000000002',
browser: '10000000-0000-4000-8000-000000000003',
results: '10000000-0000-4000-8000-000000000004'
} as const
export const DEFAULT_WORKBAR_INSTANCES: readonly WorkbarTabInstance[] =
WORKBAR_APP_DEFINITIONS.filter(
(definition) => definition.defaultOpen
).map((definition) => ({
id: DEFAULT_INSTANCE_IDS[definition.id],
appId: definition.id,
title: definition.label
}))
export type WorkbarInstanceCreateRequest = {
appId: WorkbarAppId
insertAfterInstanceId: string | null
targetRef?: WorkbarTargetRef
}
export type WorkbarShellProps = {
instances: readonly WorkbarTabInstance[]
activeInstanceId: string | null
renderPanel: (instance: WorkbarTabInstance) => ReactNode
renderTabAdornment?: (instance: WorkbarTabInstance) => ReactNode
onActiveInstanceChange: (instanceId: string) => void
onCreateInstance: (request: WorkbarInstanceCreateRequest) => void
onCloseInstance: (
instance: WorkbarTabInstance
) => boolean | void | Promise<boolean | void>
onResolveTerminalTarget: () =>
| WorkbarTargetRef
| Promise<WorkbarTargetRef>
appDefinitions?: readonly WorkbarAppDefinition[]
onResolveUnavailableApp?: (definition: WorkbarAppDefinition) => void
className?: string
}
function joinClassNames(...values: (string | false | undefined)[]): string {
return values.filter(Boolean).join(' ')
}
function adjacentIndex(
key: string,
currentIndex: number,
itemCount: number
): number | undefined {
if (key === 'Home') {
return 0
}
if (key === 'End') {
return itemCount - 1
}
if (key === 'ArrowRight') {
return (currentIndex + 1) % itemCount
}
if (key === 'ArrowLeft') {
return (currentIndex - 1 + itemCount) % itemCount
}
return undefined
}
export function WorkbarShell({
activeInstanceId,
appDefinitions = WORKBAR_APP_DEFINITIONS,
className,
instances,
onActiveInstanceChange,
onCloseInstance,
onCreateInstance,
onResolveUnavailableApp,
onResolveTerminalTarget,
renderPanel,
renderTabAdornment
}: WorkbarShellProps): React.JSX.Element {
const { t } = useTranslation('workspace')
const idPrefix = useId()
const addButtonRef = useRef<HTMLButtonElement>(null)
const catalogRef = useRef<HTMLDivElement>(null)
const returnInstanceIdRef = useRef<string | null>(null)
const pendingTabFocusRef = useRef<string | null | undefined>(undefined)
const focusCreatedInstanceRef = useRef(false)
const [catalogOpen, setCatalogOpen] = useState(false)
const [creatingAppId, setCreatingAppId] =
useState<WorkbarAppId | null>(null)
const [targetError, setTargetError] = useState<string | null>(null)
const activeInstance =
instances.find((instance) => instance.id === activeInstanceId) ??
null
const tabId = useCallback(
(instanceId: string): string =>
`${idPrefix}-tab-${instanceId}`,
[idPrefix]
)
const panelId = (instanceId: string): string =>
`${idPrefix}-panel-${instanceId}`
useEffect(() => {
if (!catalogOpen) {
return
}
const firstAvailable =
catalogRef.current?.querySelector<HTMLButtonElement>(
'[data-workbar-app-choice]:not(:disabled)'
)
firstAvailable?.focus()
}, [catalogOpen])
useEffect(() => {
if (
focusCreatedInstanceRef.current &&
!catalogOpen &&
activeInstanceId
) {
const activeTab = document.getElementById(tabId(activeInstanceId))
if (activeTab instanceof HTMLButtonElement) {
activeTab.focus()
focusCreatedInstanceRef.current = false
}
}
const pendingId = pendingTabFocusRef.current
if (pendingId === undefined) {
return
}
if (pendingId === null) {
addButtonRef.current?.focus()
pendingTabFocusRef.current = undefined
return
}
const tab = document.getElementById(tabId(pendingId))
if (tab instanceof HTMLButtonElement) {
tab.focus()
pendingTabFocusRef.current = undefined
}
}, [activeInstanceId, catalogOpen, instances, tabId])
const focusAndActivate = (instanceId: string): void => {
onActiveInstanceChange(instanceId)
const tab = document.getElementById(tabId(instanceId))
if (tab instanceof HTMLButtonElement) {
tab.focus()
}
}
const closeCatalog = (restoreToAddButton: boolean): void => {
setCatalogOpen(false)
setCreatingAppId(null)
setTargetError(null)
const returnInstanceId = returnInstanceIdRef.current
if (
returnInstanceId &&
instances.some((instance) => instance.id === returnInstanceId)
) {
onActiveInstanceChange(returnInstanceId)
}
if (restoreToAddButton) {
queueMicrotask(() => addButtonRef.current?.focus())
}
}
const openCatalog = (): void => {
returnInstanceIdRef.current = activeInstanceId
setTargetError(null)
setCatalogOpen(true)
}
const chooseApp = async (
definition: WorkbarAppDefinition
): Promise<void> => {
if (definition.availability.state === 'unavailable') {
return
}
if (definition.instancePolicy === 'single') {
const existing = instances.find(
(instance) => instance.appId === definition.id
)
if (existing) {
setCatalogOpen(false)
setTargetError(null)
pendingTabFocusRef.current = existing.id
onActiveInstanceChange(existing.id)
return
}
}
if (instances.length >= WORKBAR_LIMITS.maximumOpenInstances) {
return
}
const insertAfterInstanceId = activeInstanceId
if (definition.id !== 'terminal') {
focusCreatedInstanceRef.current = true
setCatalogOpen(false)
onCreateInstance({
appId: definition.id,
insertAfterInstanceId
})
return
}
setCreatingAppId('terminal')
setTargetError(null)
try {
const targetRef = await onResolveTerminalTarget()
focusCreatedInstanceRef.current = true
setCatalogOpen(false)
onCreateInstance({
appId: 'terminal',
insertAfterInstanceId,
targetRef
})
} catch (error) {
setTargetError(
error instanceof Error
? error.message
: t('sidebar.workbar.targetError')
)
} finally {
setCreatingAppId(null)
}
}
const handleTabKeyDown = (
event: KeyboardEvent<HTMLButtonElement>,
index: number
): void => {
if (event.key === 'Escape') {
event.preventDefault()
if (catalogOpen) {
closeCatalog(false)
}
focusAndActivate(activeInstance?.id ?? instances[index]!.id)
return
}
const nextIndex = adjacentIndex(
event.key,
index,
instances.length
)
if (nextIndex === undefined) {
return
}
event.preventDefault()
focusAndActivate(instances[nextIndex]!.id)
}
const handleClose = async (
instance: WorkbarTabInstance
): Promise<void> => {
const closingIndex = instances.findIndex(
(candidate) => candidate.id === instance.id
)
const isActive = instance.id === activeInstanceId
const nextActive = isActive
? (instances[closingIndex + 1] ??
instances[closingIndex - 1] ??
null)
: activeInstance
const closeResult = onCloseInstance(instance)
const closeAccepted =
closeResult &&
typeof (closeResult as PromiseLike<boolean | void>).then ===
'function'
? await closeResult
: closeResult
if (closeAccepted === false) {
return
}
pendingTabFocusRef.current = nextActive?.id ?? null
if (nextActive && nextActive.id !== activeInstanceId) {
onActiveInstanceChange(nextActive.id)
}
}
return (
<section
aria-label={t('sidebar.workbar.ariaLabel')}
className={joinClassNames('workbar-shell', className)}
>
<div className="workbar-shell__tab-row">
<div className="workbar-shell__tab-scroll">
<div
aria-label={t('sidebar.workbar.tablist')}
className="workbar-shell__tablist"
role="tablist"
>
{instances.map((instance, index) => {
const selected =
!catalogOpen && instance.id === activeInstanceId
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
return (
<div
className={joinClassNames(
'workbar-shell__tab-item',
selected && 'workbar-shell__tab-item--active'
)}
key={instance.id}
>
<button
aria-controls={panelId(instance.id)}
aria-selected={selected}
className={joinClassNames(
'workbar-shell__tab',
selected && 'workbar-shell__tab--active'
)}
id={tabId(instance.id)}
onClick={() => {
setCatalogOpen(false)
setTargetError(null)
onActiveInstanceChange(instance.id)
}}
onKeyDown={(event) =>
handleTabKeyDown(event, index)
}
role="tab"
tabIndex={selected ? 0 : -1}
title={
appDefinitions.find(
(application) =>
application.id === instance.appId
)?.description ?? instance.title
}
type="button"
>
<span
aria-hidden="true"
className="workbar-shell__tab-icon"
>
<AppIcon />
</span>
<span className="workbar-shell__tab-label">
{instance.title}
</span>
{renderTabAdornment?.(instance)}
</button>
<button
aria-label={t('sidebar.workbar.close', {
title: instance.title
})}
className="workbar-shell__tab-close"
onClick={() => void handleClose(instance)}
title={t('sidebar.workbar.close', {
title: instance.title
})}
type="button"
>
<X aria-hidden="true" />
</button>
</div>
)
})}
</div>
</div>
<button
aria-expanded={catalogOpen}
aria-label={t('sidebar.workbar.add')}
className={joinClassNames(
'workbar-shell__add',
catalogOpen && 'workbar-shell__add--active'
)}
onClick={() =>
catalogOpen ? closeCatalog(true) : openCatalog()
}
ref={addButtonRef}
title={t('sidebar.workbar.add')}
type="button"
>
<span aria-hidden="true">+</span>
</button>
</div>
<div className="workbar-shell__content">
{instances.map((instance) => (
<div
aria-labelledby={tabId(instance.id)}
className="workbar-shell__tabpanel"
hidden={catalogOpen || instance.id !== activeInstanceId}
id={panelId(instance.id)}
key={instance.id}
role="tabpanel"
tabIndex={0}
>
{renderPanel(instance)}
</div>
))}
{catalogOpen ? (
<div
className="workbar-shell__catalog"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
closeCatalog(true)
}
}}
ref={catalogRef}
>
<div className="workbar-shell__catalog-header">
<div>
<h2>{t('sidebar.workbar.catalogTitle')}</h2>
<p>{t('sidebar.workbar.catalogDescription')}</p>
</div>
<button
className="secondary-button"
onClick={() => closeCatalog(true)}
type="button"
>
{t('sidebar.workbar.cancel')}
</button>
</div>
{targetError ? (
<p className="workbar-shell__catalog-error" role="alert">
{targetError}
</p>
) : null}
<ul
aria-label={t('sidebar.workbar.catalog')}
className="workbar-shell__catalog-list"
>
{appDefinitions.map((definition) => {
const AppIcon = WORKBAR_APP_ICONS[definition.id]
const existing =
definition.instancePolicy === 'single' &&
instances.some(
(instance) => instance.appId === definition.id
)
const availability = definition.availability
const unavailable =
availability.state === 'unavailable'
const atLimit =
!existing &&
instances.length >=
WORKBAR_LIMITS.maximumOpenInstances
const disabled =
unavailable ||
atLimit ||
creatingAppId !== null
return (
<li
className="workbar-shell__catalog-item"
key={definition.id}
>
<button
className="workbar-shell__catalog-choice"
data-workbar-app-choice
disabled={disabled}
onClick={() => void chooseApp(definition)}
type="button"
>
<span
aria-hidden="true"
className={`workbar-shell__app-icon workbar-shell__app-icon--${definition.icon}`}
>
<AppIcon />
</span>
<span className="workbar-shell__catalog-copy">
<strong>{definition.label}</strong>
<span>{definition.description}</span>
<small>
{existing
? t('sidebar.workbar.existing')
: definition.instancePolicy === 'multiple'
? t('sidebar.workbar.multiple')
: t('sidebar.workbar.single')}
</small>
</span>
</button>
{availability.state === 'unavailable' ? (
<div className="workbar-shell__unavailable">
<span>{availability.reason}</span>
{onResolveUnavailableApp ? (
<button
className="secondary-button"
onClick={() =>
onResolveUnavailableApp(definition)
}
type="button"
>
{t('sidebar.workbar.resolve')}
</button>
) : null}
</div>
) : atLimit ? (
<small className="workbar-shell__limit">
{t('sidebar.workbar.limit', {
count: WORKBAR_LIMITS.maximumOpenInstances
})}
</small>
) : null}
</li>
)
})}
</ul>
</div>
) : instances.length === 0 ? (
<div className="workbar-shell__empty">
<strong>{t('sidebar.workbar.emptyTitle')}</strong>
<p>{t('sidebar.workbar.emptyDescription')}</p>
<button
className="primary-button"
onClick={openCatalog}
type="button"
>
{t('sidebar.workbar.open')}
</button>
</div>
) : null}
</div>
</section>
)
}
+3 -1
View File
@@ -62,7 +62,9 @@ export const app = {
localWorkspace: 'Local workspace',
loading: 'Loading',
close: 'Close sidebar',
toggle: 'Toggle sidebar'
toggle: 'Toggle sidebar',
resizeAriaLabel: 'Resize main sidebar',
resizeValue: 'Main sidebar, {{width}} pixels'
},
topbar: {
toggleAssistantSidebar: 'Toggle assistant workspace',
@@ -27,6 +27,8 @@ export const magicNotes = {
analyzeAgain: 'Analyze again',
analyzing: 'Analyzing…',
openSourceNote: 'Open source note',
hideListPane: 'Hide left list',
showListPane: 'Show left list',
hideAiComments: 'Hide AI comments',
showAiComments: 'Show AI comments',
pinNote: 'Pin note',
@@ -161,6 +163,8 @@ export const magicNotes = {
'Choose “Analyze with AI” on an entry to show comments here.'
},
accessibility: {
resizeListPane: 'Resize the note list and editor panes',
listPaneWidth: 'Left list, {{width}} pixels',
resizeAiPane: 'Resize the editor and AI comments panes',
aiPaneWidth: 'AI comments pane, {{width}} pixels'
},
@@ -191,6 +191,97 @@ export const workspace = {
results: {
label: 'Results',
description: 'View generated or imported standalone results'
},
terminal: {
label: 'Terminal',
description: 'Open a user terminal for the current project'
}
},
workbar: {
ariaLabel: 'Workbar applications',
tablist: 'Open workbar applications',
add: 'Open workbar application',
close: 'Close {{title}}',
catalogTitle: 'New workbar application',
catalogDescription: 'Choose an application to open in the workbar.',
cancel: 'Cancel',
targetError:
'Could not resolve the current project terminal target. Try again.',
catalog: 'Workbar application catalog',
existing: 'Already open. Selecting switches to the existing instance.',
multiple: 'Multiple instances supported',
single: 'Single-instance application',
resolve: 'Resolve',
limit: 'You can open up to {{count}} instances.',
emptyTitle: 'No workbar applications are open',
emptyDescription: 'Open an application to switch to it here.',
open: 'Open application'
},
terminal: {
loading: 'Loading terminal…',
ariaLabel: 'User terminal: {{title}}',
location: {
local: 'Local',
target: 'Target'
},
states: {
starting: 'Connecting',
running: 'Connected',
exited: 'Shell exited',
interrupted: 'Connection interrupted',
closing: 'Closing',
failed: 'Connection failed',
creating: 'Creating'
},
toolbar: {
ariaLabel: 'Terminal actions',
search: 'Search terminal',
copy: 'Copy selection',
paste: 'Paste',
selectAll: 'Select all',
selectAllAriaLabel: 'Select all terminal content',
clear: 'Clear terminal',
rename: 'Rename terminal'
},
reopen: {
interrupted: 'Reconnect with a new shell',
default: 'Reopen'
},
search: {
label: 'Search terminal content',
next: 'Find next'
},
rename: {
label: 'Terminal name',
save: 'Save name',
cancel: 'Cancel'
},
status: {
emulatorAriaLabel: 'Terminal input and output',
ariaLabel: 'Terminal status',
readingDirectory: 'Reading directory',
readingShell: 'Reading shell',
exitCode: '(exit code {{code}})'
},
errors: {
ack: 'Could not acknowledge terminal output. Try again.',
write: 'Could not write input to the terminal.',
snapshot: 'Could not read the terminal session. Reopen it.',
create: 'Could not create the terminal. Try again.',
resize: 'Could not synchronize the terminal size.',
copy: 'Could not copy the terminal selection.',
paste: 'Could not paste into the terminal.',
closePrevious: 'Could not close the previous terminal. Try again.'
},
closeDialog: {
ariaLabel: 'Close terminal',
title: 'Close terminal?',
description:
'Closing this tab ends the shell and its child processes.',
cancel: 'Cancel',
closing: 'Closing…',
confirm: 'Close terminal',
error: 'Could not close the terminal. Try again.'
}
},
tasks: {
+3 -1
View File
@@ -59,7 +59,9 @@ export const app = {
localWorkspace: '本地工作区',
loading: '加载中',
close: '关闭侧栏',
toggle: '切换侧栏'
toggle: '切换侧栏',
resizeAriaLabel: '调整主侧栏宽度',
resizeValue: '主侧栏 {{width}} 像素'
},
topbar: {
toggleAssistantSidebar: '切换助手工作栏',
@@ -23,6 +23,8 @@ export const magicNotes = {
analyzeAgain: '重新分析',
analyzing: '分析中…',
openSourceNote: '打开原笔记修改',
hideListPane: '隐藏左侧列表',
showListPane: '显示左侧列表',
hideAiComments: '隐藏 AI 评论',
showAiComments: '显示 AI 评论',
pinNote: '置顶笔记',
@@ -149,6 +151,8 @@ export const magicNotes = {
manualHint: '在记录上点击“AI 分析”,评论会显示在这里。'
},
accessibility: {
resizeListPane: '调整笔记列表与编辑区宽度',
listPaneWidth: '左侧列表 {{width}} 像素',
resizeAiPane: '调整编辑区与 AI 评论宽度',
aiPaneWidth: 'AI 评论栏 {{width}} 像素'
},
@@ -185,6 +185,95 @@ export const workspace = {
results: {
label: '成果',
description: '查看生成或手动导入的独立成果'
},
terminal: {
label: '终端',
description: '打开当前项目的用户终端'
}
},
workbar: {
ariaLabel: '工作栏应用',
tablist: '已打开的工作栏应用',
add: '打开工作栏应用',
close: '关闭{{title}}',
catalogTitle: '新建工作栏应用',
catalogDescription: '选择要在工作栏中打开的应用。',
cancel: '取消',
targetError: '无法确定当前项目的终端目标,请重试。',
catalog: '工作栏应用目录',
existing: '已打开,选择后切换到现有实例',
multiple: '可打开多个实例',
single: '单实例应用',
resolve: '前往处理',
limit: '最多可打开 {{count}} 个实例。',
emptyTitle: '还没有打开的工作栏应用',
emptyDescription: '打开应用后,可以在这里快速切换和处理内容。',
open: '打开应用'
},
terminal: {
loading: '正在加载终端…',
ariaLabel: '用户终端:{{title}}',
location: {
local: '本机',
target: '目标'
},
states: {
starting: '正在连接',
running: '已连接',
exited: 'Shell 已退出',
interrupted: '连接已中断',
closing: '正在关闭',
failed: '连接失败',
creating: '正在创建'
},
toolbar: {
ariaLabel: '终端操作',
search: '搜索终端',
copy: '复制选中内容',
paste: '粘贴',
selectAll: '全选',
selectAllAriaLabel: '全选终端内容',
clear: '清屏',
rename: '重命名终端'
},
reopen: {
interrupted: '重新连接并新建 Shell',
default: '重新打开'
},
search: {
label: '搜索终端内容',
next: '查找下一个'
},
rename: {
label: '终端名称',
save: '保存名称',
cancel: '取消'
},
status: {
emulatorAriaLabel: '终端输入与输出',
ariaLabel: '终端状态',
readingDirectory: '正在读取目录',
readingShell: '正在读取 Shell',
exitCode: '(退出码 {{code}})'
},
errors: {
ack: '无法确认终端输出,请重试。',
write: '无法向终端写入输入。',
snapshot: '无法读取终端会话,请重新打开。',
create: '无法创建终端,请重试。',
resize: '无法同步终端窗口尺寸。',
copy: '无法复制终端选区。',
paste: '无法粘贴到终端。',
closePrevious: '无法关闭原终端,请重试。'
},
closeDialog: {
ariaLabel: '关闭终端',
title: '关闭终端?',
description: '关闭此 Tab 将结束 Shell 及其子进程。',
cancel: '取消',
closing: '正在关闭…',
confirm: '关闭终端',
error: '无法关闭终端,请重试。'
}
},
tasks: {
+482 -17
View File
@@ -76,7 +76,9 @@
"Microsoft YaHei UI", "Noto Sans SC Variable", "Noto Sans CJK SC",
sans-serif;
--font-family-mono:
"Cascadia Code", "SFMono-Regular", Consolas, monospace;
"Cascadia Mono", "Cascadia Code", "SFMono-Regular", Menlo, Monaco,
Consolas, "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono",
monospace;
font-family: var(--font-family-ui);
font-size: var(--font-body);
font-optical-sizing: auto;
@@ -1250,6 +1252,7 @@
height: 100%;
flex-direction: column;
gap: var(--space-4);
container: magic-notes-page / inline-size;
}
.magic-notes-layout {
@@ -1261,14 +1264,26 @@
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
container: speech-model-list / inline-size;
grid-template-columns:
220px minmax(300px, 1fr) 9px
var(--magic-notes-list-width, 220px) 9px
minmax(300px, 1fr) 9px
var(--magic-notes-ai-width, 280px);
}
.magic-notes-layout--ai-hidden {
grid-template-columns: 220px minmax(300px, 1fr);
grid-template-columns:
var(--magic-notes-list-width, 220px) 9px
minmax(300px, 1fr);
}
.magic-notes-layout--list-hidden {
grid-template-columns:
minmax(300px, 1fr) 9px
var(--magic-notes-ai-width, 280px);
}
.magic-notes-layout--list-hidden.magic-notes-layout--ai-hidden {
grid-template-columns: minmax(300px, 1fr);
}
.magic-notes-layout--resizing {
@@ -1276,21 +1291,34 @@
user-select: none;
}
.magic-notes-list-resize-handle,
.magic-notes-ai-resize-handle {
position: relative;
z-index: 2;
width: 9px;
min-width: 9px;
padding: 0;
cursor: col-resize;
touch-action: none;
}
.magic-notes-list-resize-handle {
background: linear-gradient(
to right,
var(--surface-subtle) 0 4px,
var(--surface-raised) 4px 100%
);
}
.magic-notes-ai-resize-handle {
background: linear-gradient(
to right,
var(--surface-raised) 0 4px,
var(--surface-subtle) 4px 100%
);
cursor: col-resize;
touch-action: none;
}
.magic-notes-list-resize-handle::after,
.magic-notes-ai-resize-handle::after {
position: absolute;
top: 0;
@@ -1304,14 +1332,19 @@
background var(--motion-fast) ease-out;
}
.magic-notes-list-resize-handle:hover::after,
.magic-notes-list-resize-handle:focus-visible::after,
.magic-notes-ai-resize-handle:hover::after,
.magic-notes-ai-resize-handle:focus-visible::after,
.magic-notes-layout--resizing
.magic-notes-layout--list-resizing
> .magic-notes-list-resize-handle::after,
.magic-notes-layout--ai-resizing
> .magic-notes-ai-resize-handle::after {
width: 2px;
background: var(--accent);
}
.magic-notes-list-resize-handle:focus-visible,
.magic-notes-ai-resize-handle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
@@ -1330,10 +1363,14 @@
background: var(--surface-subtle);
}
.magic-notes-list-pane[hidden],
.magic-notes-ai-pane[hidden] {
display: none;
}
.magic-notes-list-pane {
display: flex;
padding: var(--space-4);
border-right: 1px solid var(--border-subtle);
flex-direction: column;
gap: var(--space-3);
}
@@ -2175,12 +2212,18 @@
cursor: pointer;
}
@container (max-width: 800px) {
@container magic-notes-page (max-width: 800px) {
.magic-notes-layout {
grid-template-columns: 210px minmax(320px, 1fr);
grid-template-columns:
var(--magic-notes-list-width, 210px) 9px
minmax(320px, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
}
.magic-notes-layout--list-hidden {
grid-template-columns: minmax(320px, 1fr);
}
.magic-notes-ai-resize-handle {
display: none;
}
@@ -2193,11 +2236,7 @@
}
}
@container (max-width: 720px) {
.magic-notes-page {
height: auto;
}
@container magic-notes-page (max-width: 720px) {
.magic-notes-layout {
min-height: 0;
overflow: visible;
@@ -2205,6 +2244,10 @@
grid-template-rows: auto auto auto;
}
.magic-notes-list-resize-handle {
display: none;
}
.magic-notes-list-pane {
max-height: 240px;
border-right: 0;
@@ -2321,7 +2364,7 @@ textarea:focus-visible {
.sidebar {
display: flex;
flex: 0 0 278px;
flex: 0 0 var(--primary-sidebar-width, 278px);
flex-direction: column;
min-width: 0;
padding: 20px 14px 14px;
@@ -2334,12 +2377,63 @@ textarea:focus-visible {
padding 180ms ease;
}
.sidebar--resizing {
transition: none;
}
.sidebar--closed {
flex-basis: 0;
padding-right: 0;
padding-left: 0;
}
.sidebar > .brand,
.sidebar > .project-switcher,
.sidebar > .new-chat,
.sidebar > .sidebar-search,
.sidebar > .primary-nav,
.sidebar > .conversation-list,
.sidebar > .sidebar-footer {
min-width: 0;
}
.primary-sidebar-resize-handle {
position: relative;
z-index: 3;
width: 9px;
min-width: 9px;
padding: 0;
margin-left: -5px;
background: transparent;
cursor: col-resize;
touch-action: none;
}
.primary-sidebar-resize-handle::after {
position: absolute;
top: 0;
bottom: 0;
left: 4px;
width: 1px;
background: var(--border-subtle);
content: '';
transition:
width var(--motion-fast) ease-out,
background var(--motion-fast) ease-out;
}
.primary-sidebar-resize-handle:hover::after,
.primary-sidebar-resize-handle:focus-visible::after,
.sidebar--resizing + .primary-sidebar-resize-handle::after {
width: 2px;
background: var(--accent);
}
.primary-sidebar-resize-handle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.sidebar-backdrop {
display: none;
}
@@ -3714,6 +3808,372 @@ button > svg {
outline-offset: -2px;
}
.workbar-shell {
display: flex;
min-width: 0;
min-height: 0;
flex: 1;
flex-direction: column;
}
.workbar-shell__tab-row {
display: flex;
min-width: 0;
min-height: 46px;
padding: 6px 46px 0 8px;
border-bottom: 1px solid var(--border-default);
background: var(--surface-subtle);
flex: 0 0 46px;
align-items: flex-end;
gap: var(--space-1);
}
.workbar-shell__tab-scroll {
min-width: 0;
flex: 1;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.workbar-shell__tab-scroll::-webkit-scrollbar {
display: none;
}
.workbar-shell__tablist {
display: flex;
width: max-content;
min-width: 100%;
align-items: flex-end;
gap: 2px;
}
.workbar-shell__tab-item {
position: relative;
display: flex;
min-width: 88px;
max-width: 180px;
height: 39px;
align-items: center;
border: 1px solid transparent;
border-bottom: 0;
border-radius: var(--radius-control) var(--radius-control) 0 0;
background: transparent;
transition:
border-color var(--motion-fast) ease-out,
background var(--motion-fast) ease-out,
box-shadow var(--motion-fast) ease-out;
}
.workbar-shell__tab-item:hover {
background: var(--surface-muted);
}
.workbar-shell__tab-item--active {
border-color: var(--border-default);
background: var(--surface-raised);
box-shadow: inset 0 2px 0 var(--accent);
}
.workbar-shell__tab {
position: relative;
display: flex;
min-width: 0;
height: 100%;
padding: 0 32px 0 var(--space-2);
align-items: center;
flex: 1;
gap: 6px;
overflow: hidden;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: var(--font-caption);
text-align: left;
}
.workbar-shell__tab:hover {
color: var(--text-primary);
}
.workbar-shell__tab--active {
color: var(--text-primary);
font-weight: 600;
}
.workbar-shell__tab-icon {
display: grid;
width: 15px;
height: 15px;
flex: 0 0 15px;
place-items: center;
color: var(--text-muted);
}
.workbar-shell__tab-icon > svg {
width: 15px;
height: 15px;
stroke-width: 1.8;
}
.workbar-shell__tab--active .workbar-shell__tab-icon {
color: var(--accent);
}
.workbar-shell__tab-label {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workbar-shell__tab-close,
.workbar-shell__add {
display: grid;
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
place-items: center;
border-radius: var(--radius-control);
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.workbar-shell__tab-close {
position: absolute;
z-index: 1;
top: 50%;
right: var(--space-1);
width: 24px;
min-width: 24px;
height: 24px;
background: var(--surface-raised);
opacity: 0;
pointer-events: none;
transform: translateY(-50%) scale(0.86);
transition:
opacity var(--motion-fast) ease-out,
transform var(--motion-fast) ease-out,
background var(--motion-fast) ease-out,
color var(--motion-fast) ease-out;
}
.workbar-shell__tab-close > svg {
width: 13px;
height: 13px;
stroke-width: 2;
}
.workbar-shell__tab-item:hover .workbar-shell__tab-close,
.workbar-shell__tab-item:focus-within .workbar-shell__tab-close {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.workbar-shell__tab-close:hover {
background: var(--danger-subtle);
color: var(--danger);
}
@media (hover: none), (pointer: coarse) {
.workbar-shell__tab-close {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%);
}
}
.workbar-shell__add:hover,
.workbar-shell__add--active {
background: var(--accent-selected);
color: var(--accent);
}
.workbar-shell__add {
margin-bottom: 5px;
background: var(--accent-subtle);
color: var(--accent);
}
.workbar-shell__content,
.workbar-shell__tabpanel {
min-width: 0;
min-height: 0;
flex: 1;
}
.workbar-shell__content {
display: flex;
flex-direction: column;
}
.workbar-shell__tabpanel {
height: 100%;
overflow: hidden;
}
.workbar-shell__tabpanel[hidden] {
display: none;
}
.workbar-shell__catalog,
.workbar-shell__empty {
min-height: 0;
padding: var(--space-4);
overflow-y: auto;
}
.workbar-shell__catalog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.workbar-shell__catalog-header h2,
.workbar-shell__empty strong {
margin: 0;
color: var(--text-primary);
font-size: var(--font-section-title);
}
.workbar-shell__catalog-header p,
.workbar-shell__empty p {
margin: var(--space-1) 0 0;
color: var(--text-secondary);
font-size: var(--font-body);
}
.workbar-shell__catalog-list {
display: flex;
padding: 0;
margin: var(--space-4) 0 0;
flex-direction: column;
gap: var(--space-2);
list-style: none;
}
.workbar-shell__catalog-item {
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
}
.workbar-shell__catalog-choice {
display: flex;
width: 100%;
padding: var(--space-3);
align-items: flex-start;
border-radius: var(--radius-card);
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
}
.workbar-shell__app-icon {
display: grid;
width: 32px;
height: 32px;
margin-right: var(--space-3);
flex: 0 0 32px;
place-items: center;
border-radius: var(--radius-control);
background: var(--accent-subtle);
color: var(--accent);
}
.workbar-shell__app-icon > svg {
width: 17px;
height: 17px;
stroke-width: 1.8;
}
.workbar-shell__catalog-choice:hover:not(:disabled) {
background: var(--accent-subtle);
}
.workbar-shell__catalog-choice:disabled {
cursor: not-allowed;
opacity: 0.68;
}
.workbar-shell__catalog-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-1);
}
.workbar-shell__catalog-copy span,
.workbar-shell__catalog-copy small,
.workbar-shell__unavailable,
.workbar-shell__limit {
color: var(--text-secondary);
font-size: var(--font-caption);
}
.workbar-shell__unavailable {
display: flex;
padding: 0 var(--space-3) var(--space-3);
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.workbar-shell__catalog-error {
padding: var(--space-2);
border: 1px solid var(--danger-border);
border-radius: var(--radius-control);
margin: var(--space-3) 0 0;
background: var(--danger-subtle);
color: var(--danger);
}
.workbar-shell__empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
text-align: center;
}
.workbar-shell__empty .primary-button {
margin-top: var(--space-3);
}
.assistant-sidebar__overlay {
position: absolute;
z-index: 4;
inset: 46px 0 0;
overflow-y: auto;
border-top: 1px solid var(--border-subtle);
background: var(--surface-raised);
}
.assistant-sidebar__terminal-close {
display: flex;
min-height: 100%;
padding: var(--space-6);
align-items: flex-start;
justify-content: center;
flex-direction: column;
gap: var(--space-3);
}
.assistant-sidebar__terminal-close h2,
.assistant-sidebar__terminal-close p {
margin: 0;
}
.assistant-sidebar__terminal-close > div {
display: flex;
gap: var(--space-2);
}
.assistant-sidebar__tabs {
display: grid;
min-height: 46px;
@@ -3767,6 +4227,7 @@ button > svg {
}
.assistant-sidebar__body {
height: 100%;
min-height: 0;
flex: 1;
overflow-y: auto;
@@ -14829,7 +15290,7 @@ details.settings-section > :not(summary) + :not(summary) {
@media (max-width: 1020px) {
.sidebar {
flex-basis: 236px;
flex-basis: var(--primary-sidebar-width, 236px);
}
.brand,
@@ -14877,6 +15338,10 @@ details.settings-section > :not(summary) + :not(summary) {
}
@media (max-width: 899px) {
.primary-sidebar-resize-handle {
display: none;
}
.sidebar {
position: absolute;
z-index: 40;
+194
View File
@@ -0,0 +1,194 @@
.terminal-panel {
display: flex;
min-width: 0;
min-height: 300px;
height: 100%;
flex-direction: column;
overflow: hidden;
color: var(--text-primary);
background: var(--surface-raised);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
}
.terminal-panel--loading {
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.terminal-panel__toolbar,
.terminal-panel__inline-form,
.terminal-panel__status,
.terminal-panel__error {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-subtle);
}
.terminal-panel__toolbar button,
.terminal-panel__inline-form button,
.terminal-panel__error button {
display: inline-flex;
min-width: 32px;
min-height: 32px;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
color: var(--text-secondary);
font: inherit;
background: var(--surface-raised);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
cursor: pointer;
}
.terminal-panel__toolbar button:hover:not(:disabled),
.terminal-panel__inline-form button:hover:not(:disabled),
.terminal-panel__error button:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--accent);
}
.terminal-panel__toolbar button:focus-visible,
.terminal-panel__inline-form button:focus-visible,
.terminal-panel__inline-form input:focus-visible,
.terminal-panel__error button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.terminal-panel__toolbar button:disabled,
.terminal-panel__inline-form button:disabled,
.terminal-panel__error button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.terminal-panel__toolbar button svg {
width: 16px;
height: 16px;
}
.terminal-panel__toolbar-spacer {
flex: 1;
}
.terminal-panel__toolbar .terminal-panel__reopen {
color: var(--accent);
}
.terminal-panel__inline-form {
flex-wrap: wrap;
color: var(--text-secondary);
font-size: var(--font-body);
}
.terminal-panel__inline-form input {
min-width: 120px;
min-height: 32px;
flex: 1 1 160px;
padding: var(--space-1) var(--space-2);
color: var(--text-primary);
font: inherit;
background: var(--surface-raised);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
}
.terminal-panel__error {
justify-content: space-between;
color: var(--danger);
background: var(--danger-subtle);
}
.terminal-panel__emulator {
min-width: 0;
min-height: 0;
flex: 1;
padding: var(--space-2);
overflow: hidden;
background: var(--surface-canvas);
}
.terminal-panel__emulator .xterm {
height: 100%;
}
.terminal-panel__status {
min-width: 0;
flex-wrap: wrap;
border-top: 1px solid var(--border-default);
border-bottom: 0;
color: var(--text-muted);
font-size: var(--font-caption);
}
.terminal-panel__status span {
min-width: 0;
max-width: 40%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-panel__status span + span::before {
content: "·";
margin-right: var(--space-2);
color: var(--border-control);
}
.terminal-panel__status strong {
margin-left: auto;
color: var(--text-secondary);
white-space: nowrap;
}
.terminal-panel__status strong[data-state="running"] {
color: var(--success);
}
.terminal-panel__status strong[data-state="interrupted"],
.terminal-panel__status strong[data-state="failed"] {
color: var(--danger);
}
.terminal-panel__status strong[data-state="starting"],
.terminal-panel__status strong[data-state="closing"] {
color: var(--warning);
}
.terminal-panel__announcement {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 720px) {
.terminal-panel__toolbar {
flex-wrap: wrap;
}
.terminal-panel__toolbar-spacer {
display: none;
}
.terminal-panel__status span {
max-width: 100%;
}
.terminal-panel__status strong {
width: 100%;
margin-left: 0;
}
}
+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
>