Let users retain useful assistant replies or complete conversations without leaving their chat workflow. Preview and edit captured text, choose an existing note or atomically create a note with its first entry, and return to the source conversation or message from saved records. Add an application-scoped Notes workbar tab with title/body search and quick text appends. Preserve drafts across conversation switches and guard closing, replacement and disabling. Schema 48 stores optional source metadata without rewriting file-backed bodies; source deletion retains the saved note. Older clients cannot reopen an upgraded database without a pre-upgrade backup. Release note: 可将助手回答或完整会话预览、编辑后加入魔法笔记,并从笔记返回原会话或消息。右侧快速笔记支持搜索和追加文字,切换会话保留草稿;采集本身不调用模型,保存后自动评论仍遵循已有设置。 Validation: isolated HEAD-based candidate passed 22 files / 930 focused tests, plus 20 gateway tests after including its create-result fixture adjustment. Real Electron App/preload/IPC/SQLite acceptance passed for all 502 historical messages, exact-entry/source navigation, native keyboard input in a 214px panel, reopen and source-deletion retention, with zero capture model calls. Repository lint and web TypeScript passed. Cached whitespace and secret-pattern checks passed; native-client changes and unrelated IPC extraction are excluded. Validation limits: the isolated source tree reuses installed node_modules, where a parallel task upgraded DSH from the committed 0.1.2-rc.1 to 0.1.7-rc.2. Full typecheck reports 14 DSH errors in three unchanged files; the full-suite attempt with --bail=1 also fails in deepseek-harness-host.test.ts. These are not all-green repository or release-package results. Earlier execution-time full suite failures and fixture repairs remain recorded in the feature progress doc.
429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
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',
|
|
'notes',
|
|
'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',
|
|
'current-conversation'
|
|
])
|
|
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,
|
|
visibleAcrossContextSwitches: z.boolean(),
|
|
defaultOpen: z.boolean(),
|
|
required: z.boolean(),
|
|
closable: z.boolean(),
|
|
reorderable: 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',
|
|
visibleAcrossContextSwitches: true,
|
|
label: '任务中心',
|
|
icon: 'tasks',
|
|
description: '查看和管理当前任务。',
|
|
instancePolicy: 'single',
|
|
defaultContext: 'current-project',
|
|
defaultOpen: true,
|
|
required: true,
|
|
closable: false,
|
|
reorderable: false,
|
|
availability: { state: 'available' }
|
|
},
|
|
{
|
|
id: 'workspace',
|
|
visibleAcrossContextSwitches: true,
|
|
label: '工作区',
|
|
icon: 'workspace',
|
|
description: '查看当前项目的工作区。',
|
|
instancePolicy: 'single',
|
|
defaultContext: 'current-project',
|
|
defaultOpen: true,
|
|
required: true,
|
|
closable: false,
|
|
reorderable: false,
|
|
availability: { state: 'available' }
|
|
},
|
|
{
|
|
id: 'browser',
|
|
visibleAcrossContextSwitches: true,
|
|
label: '浏览器',
|
|
icon: 'browser',
|
|
description: '浏览任务相关内容。',
|
|
instancePolicy: 'multiple',
|
|
defaultContext: 'current-conversation',
|
|
defaultOpen: true,
|
|
required: false,
|
|
closable: true,
|
|
reorderable: true,
|
|
availability: { state: 'available' }
|
|
},
|
|
{
|
|
id: 'results',
|
|
visibleAcrossContextSwitches: true,
|
|
label: '成果',
|
|
icon: 'results',
|
|
description: '查看任务生成的成果。',
|
|
instancePolicy: 'single',
|
|
defaultContext: 'current-project',
|
|
defaultOpen: true,
|
|
required: false,
|
|
closable: true,
|
|
reorderable: true,
|
|
availability: { state: 'available' }
|
|
},
|
|
{
|
|
id: 'notes',
|
|
visibleAcrossContextSwitches: true,
|
|
label: '笔记',
|
|
icon: 'notes',
|
|
description: '浏览笔记和快速追加文字。',
|
|
instancePolicy: 'single',
|
|
defaultContext: 'application',
|
|
defaultOpen: false,
|
|
required: false,
|
|
closable: true,
|
|
reorderable: true,
|
|
availability: { state: 'available' }
|
|
},
|
|
{
|
|
id: 'terminal',
|
|
visibleAcrossContextSwitches: true,
|
|
label: '终端',
|
|
icon: 'terminal',
|
|
description: '打开当前项目的用户终端。',
|
|
instancePolicy: 'multiple',
|
|
defaultContext: 'current-project',
|
|
defaultOpen: false,
|
|
required: false,
|
|
closable: true,
|
|
reorderable: true,
|
|
availability: { state: 'available' }
|
|
}
|
|
] as const satisfies readonly WorkbarAppDefinition[]
|
|
|
|
export const workbarExecutionTargetRefSchema = z.discriminatedUnion('type', [
|
|
z.object({ type: z.literal('local') }).strict(),
|
|
z
|
|
.object({
|
|
type: z.literal('project'),
|
|
projectId: z.string().uuid()
|
|
})
|
|
.strict()
|
|
])
|
|
export type WorkbarExecutionTargetRef = z.infer<
|
|
typeof workbarExecutionTargetRefSchema
|
|
>
|
|
|
|
export const workbarTargetRefSchema = z.union([
|
|
workbarExecutionTargetRefSchema,
|
|
z.object({ type: z.literal('task'), taskId: z.string().min(1).max(128) }).strict(),
|
|
z
|
|
.object({
|
|
type: z.literal('conversation'),
|
|
conversationId: z.string().min(1).max(128)
|
|
})
|
|
.strict()
|
|
])
|
|
export type WorkbarTargetRef = z.infer<
|
|
typeof workbarTargetRefSchema
|
|
>
|
|
|
|
export const workbarTabInstanceSchema = z
|
|
.object({
|
|
id: z.string().uuid(),
|
|
appId: workbarAppIdSchema,
|
|
title: boundedUtf8TextSchema(WORKBAR_LIMITS.maximumTitleBytes),
|
|
defaultTitleNumber: z.number().int().positive().optional(),
|
|
targetRef: workbarTargetRefSchema.optional()
|
|
})
|
|
.strict()
|
|
.superRefine((instance, context) => {
|
|
if (instance.appId === 'notes' && instance.targetRef) {
|
|
context.addIssue({ code: 'custom', path: ['targetRef'], message: 'Notes are application scoped and do not accept target bindings' })
|
|
}
|
|
if (
|
|
instance.appId === 'terminal' &&
|
|
(!instance.targetRef || !['local', 'project'].includes(instance.targetRef.type))
|
|
) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
path: ['targetRef'],
|
|
message: 'Terminal instances require a public target reference'
|
|
})
|
|
}
|
|
if (
|
|
instance.appId === 'browser' &&
|
|
instance.targetRef &&
|
|
instance.targetRef.type !== 'conversation'
|
|
) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
path: ['targetRef'],
|
|
message: 'Browser instances only accept conversation bindings'
|
|
})
|
|
}
|
|
if (
|
|
instance.appId !== 'browser' &&
|
|
instance.appId !== 'tasks' &&
|
|
instance.targetRef?.type === 'conversation'
|
|
) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
path: ['targetRef'],
|
|
message: 'Conversation bindings require browser or tasks instances'
|
|
})
|
|
}
|
|
if (instance.targetRef?.type === 'task' && instance.appId !== 'tasks') {
|
|
context.addIssue({ code: 'custom', path: ['targetRef'], message: 'Task bindings require tasks instances' })
|
|
}
|
|
})
|
|
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 workbarTaskScopeSchema = z.enum([
|
|
'current-project',
|
|
'global',
|
|
'all-projects'
|
|
])
|
|
export type WorkbarTaskScope = z.infer<typeof workbarTaskScopeSchema>
|
|
|
|
const workbarLayoutShapeSchema = 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),
|
|
taskScope: workbarTaskScopeSchema.default('current-project')
|
|
})
|
|
.strict()
|
|
|
|
export const workbarLayoutPreferencesSchema = workbarLayoutShapeSchema
|
|
.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
|
|
>
|
|
|
|
export function orderWorkbarInstances(
|
|
instances: readonly WorkbarTabInstance[]
|
|
): WorkbarTabInstance[] {
|
|
return [
|
|
...instances.filter((instance) => instance.appId === 'tasks'),
|
|
...instances.filter((instance) => instance.appId === 'workspace'),
|
|
...instances.filter(
|
|
(instance) => instance.appId !== 'tasks' && instance.appId !== 'workspace'
|
|
)
|
|
]
|
|
}
|
|
|
|
export function normalizeWorkbarLayoutPreferences(
|
|
value: unknown,
|
|
defaultInstances: readonly WorkbarTabInstance[]
|
|
): WorkbarLayoutPreferences | undefined {
|
|
const parsed = workbarLayoutShapeSchema.safeParse(value)
|
|
if (!parsed.success) {
|
|
return undefined
|
|
}
|
|
|
|
const instanceIds = new Set<string>()
|
|
const seenSingleApps = new Set<WorkbarAppId>()
|
|
const instances: WorkbarTabInstance[] = []
|
|
for (const instance of parsed.data.instances) {
|
|
if (
|
|
instanceIds.has(instance.id) ||
|
|
(singleInstanceAppIds.has(instance.appId) &&
|
|
seenSingleApps.has(instance.appId))
|
|
) {
|
|
continue
|
|
}
|
|
instanceIds.add(instance.id)
|
|
if (singleInstanceAppIds.has(instance.appId)) {
|
|
seenSingleApps.add(instance.appId)
|
|
}
|
|
instances.push(instance)
|
|
}
|
|
|
|
for (const defaultInstance of defaultInstances) {
|
|
const definition = WORKBAR_APP_DEFINITIONS.find(
|
|
(candidate) => candidate.id === defaultInstance.appId
|
|
)
|
|
if (
|
|
definition?.required !== true ||
|
|
instances.some(
|
|
(instance) => instance.appId === defaultInstance.appId
|
|
)
|
|
) {
|
|
continue
|
|
}
|
|
const collidingIdIndex = instances.findIndex(
|
|
(instance) => instance.id === defaultInstance.id
|
|
)
|
|
if (collidingIdIndex >= 0) {
|
|
const [removed] = instances.splice(collidingIdIndex, 1)
|
|
if (removed && singleInstanceAppIds.has(removed.appId)) {
|
|
seenSingleApps.delete(removed.appId)
|
|
}
|
|
}
|
|
instances.splice(
|
|
WORKBAR_APP_DEFINITIONS.findIndex(
|
|
(candidate) => candidate.id === defaultInstance.appId
|
|
),
|
|
0,
|
|
defaultInstance
|
|
)
|
|
}
|
|
|
|
const boundedInstances = orderWorkbarInstances(instances).slice(
|
|
0,
|
|
WORKBAR_LIMITS.maximumOpenInstances
|
|
)
|
|
const activeInstanceId = boundedInstances.some(
|
|
(instance) => instance.id === parsed.data.activeInstanceId
|
|
)
|
|
? parsed.data.activeInstanceId
|
|
: boundedInstances[0]?.id ?? null
|
|
|
|
return workbarLayoutPreferencesSchema.parse({
|
|
...parsed.data,
|
|
instances: boundedInstances,
|
|
activeInstanceId
|
|
})
|
|
}
|