fix: preserve long run activity and large previews
Long Runtime sessions could silently stop retaining tool and Subagent activity, truncate Run history details, and reject workspace previews over 256 KB. Run history now persists in Main SQLite without the former fixed record or detail caps, surfaces possible loss from legacy caches, and renders long histories in batches. Local and remote workspace previews now load UTF-8-safe 256 KiB pages on demand instead of rejecting larger text files. The legacy migration, clear-data path, IPC contracts, tests, and feature documentation are updated with the resulting behavior. Release note: 长时间 Runtime 运行现在会完整保留工具和 Subagent 活动;运行记录迁移到 SQLite,并提示旧版可能已丢失的内容。超过 256KB 的本地及远程文本文件可通过分页面预览继续加载。
This commit is contained in:
@@ -159,11 +159,11 @@ describe('AssistantDatabase', () => {
|
||||
unchanged.close()
|
||||
})
|
||||
|
||||
it('persists more than 100 tool activities in one message', async () => {
|
||||
it('persists more than 500 tool activities in one message', async () => {
|
||||
const database = await createDatabase()
|
||||
const conversationId = '00000000-0000-4000-8000-000000000901'
|
||||
const messageId = '00000000-0000-4000-8000-000000000902'
|
||||
const tools = Array.from({ length: 101 }, (_, index) => ({
|
||||
const tools = Array.from({ length: 501 }, (_, index) => ({
|
||||
callId: `call-${index + 1}`,
|
||||
name: 'read',
|
||||
state: 'completed' as const,
|
||||
@@ -192,7 +192,79 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
expect(
|
||||
database.getConversation(conversationId)?.messages[0]?.tools
|
||||
).toHaveLength(101)
|
||||
).toHaveLength(501)
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('persists complete Run history beyond the former Renderer limits', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-activity-history-')
|
||||
)
|
||||
temporaryDirectories.push(directory)
|
||||
const databasePath = join(directory, 'assistant.sqlite')
|
||||
const records = Array.from({ length: 501 }, (_, index) => ({
|
||||
id: `activity-${index}`,
|
||||
conversationId: 'conversation-1',
|
||||
requestId: 'request-1',
|
||||
scope: { kind: 'global' as const },
|
||||
kind: 'tool' as const,
|
||||
title: `工具调用 ${index}`,
|
||||
detail: index === 500 ? 'x'.repeat(4_001) : '读取文件',
|
||||
status: 'completed' as const,
|
||||
createdAt: index
|
||||
}))
|
||||
|
||||
const initial = new AssistantDatabase(databasePath)
|
||||
initial.initialize('C:\\Workspace')
|
||||
initial.replaceActivityHistory({
|
||||
records,
|
||||
legacyHistoryMayBeIncomplete: true
|
||||
})
|
||||
initial.close()
|
||||
|
||||
const reopened = new AssistantDatabase(databasePath)
|
||||
reopened.initialize('C:\\Workspace')
|
||||
const history = reopened.getActivityHistory()
|
||||
expect(history.records).toHaveLength(501)
|
||||
expect(history.records.at(-1)?.detail).toHaveLength(4_001)
|
||||
expect(history.legacyHistoryMayBeIncomplete).toBe(true)
|
||||
|
||||
reopened.clearAssistantData()
|
||||
expect(reopened.getActivityHistory()).toEqual({
|
||||
records: [],
|
||||
legacyHistoryMayBeIncomplete: false
|
||||
})
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('keeps the truthful truncation marker on legacy messages', async () => {
|
||||
const database = await createDatabase()
|
||||
const conversationId = '00000000-0000-4000-8000-000000000911'
|
||||
|
||||
database.saveLocalConversations([
|
||||
{
|
||||
header: {
|
||||
id: conversationId,
|
||||
title: '旧版截断记录',
|
||||
updatedAt: 1
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000912',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: 1,
|
||||
state: 'complete',
|
||||
displayCaptureTruncated: true
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
expect(
|
||||
database.getConversation(conversationId).messages[0]
|
||||
?.displayCaptureTruncated
|
||||
).toBe(true)
|
||||
database.close()
|
||||
})
|
||||
|
||||
@@ -239,7 +311,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 32', async () => {
|
||||
it('migrates existing databases to schema version 33', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||
)
|
||||
@@ -268,7 +340,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(32)
|
||||
).toBe(33)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -374,6 +446,14 @@ describe('AssistantDatabase', () => {
|
||||
)
|
||||
.get()
|
||||
).toEqual({ name: 'conversations_branch_source_idx' })
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND name = 'activity_history'`
|
||||
)
|
||||
.get()
|
||||
).toEqual({ name: 'activity_history' })
|
||||
current.close()
|
||||
})
|
||||
|
||||
@@ -430,7 +510,7 @@ describe('AssistantDatabase', () => {
|
||||
enableForeignKeyConstraints: true
|
||||
})
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 32
|
||||
user_version: 33
|
||||
})
|
||||
expect(
|
||||
inspected.prepare('SELECT * FROM projects ORDER BY rowid').all()
|
||||
@@ -528,7 +608,7 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 32
|
||||
user_version: 33
|
||||
})
|
||||
expect(
|
||||
inspected
|
||||
@@ -605,7 +685,7 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 32
|
||||
user_version: 33
|
||||
})
|
||||
expect(
|
||||
inspected
|
||||
@@ -855,7 +935,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(32)
|
||||
).toBe(33)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -993,7 +1073,7 @@ describe('AssistantDatabase', () => {
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(
|
||||
inspected.prepare('PRAGMA user_version').get()
|
||||
).toEqual({ user_version: 32 })
|
||||
).toEqual({ user_version: 33 })
|
||||
expect(
|
||||
inspected
|
||||
.prepare(
|
||||
@@ -2690,7 +2770,7 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
const inspected = new DatabaseSync(databasePath)
|
||||
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
|
||||
user_version: 32
|
||||
user_version: 33
|
||||
})
|
||||
expect(
|
||||
inspected.prepare('PRAGMA table_info(task_events)').all()
|
||||
|
||||
@@ -3,12 +3,11 @@ import { DatabaseSync } from 'node:sqlite'
|
||||
import {
|
||||
builtInDefaultProjectSeedDescription,
|
||||
builtInDefaultProjectSeedName,
|
||||
activityHistorySnapshotSchema,
|
||||
assistantIdSchema,
|
||||
conversationMessageSchema,
|
||||
conversationSnapshotSchema,
|
||||
expertCreateSchema,
|
||||
maximumConversationMessageBlocks,
|
||||
maximumConversationRetainedActivities,
|
||||
normalizeInteractiveWorkMode,
|
||||
persistedProjectExecutionSpaceSchema,
|
||||
projectCreateSchema,
|
||||
@@ -24,6 +23,7 @@ import type {
|
||||
AssistantProject,
|
||||
AssistantSchedule,
|
||||
AssistantTask,
|
||||
ActivityHistorySnapshot,
|
||||
ConversationQueueItem,
|
||||
ConversationBranchInput,
|
||||
ConversationMessageBlock,
|
||||
@@ -95,7 +95,7 @@ import {
|
||||
} from '../magic-notes/rich-content'
|
||||
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 32
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 33
|
||||
|
||||
export type RemoteTaskEventInput = {
|
||||
taskId: string
|
||||
@@ -1123,6 +1123,8 @@ function toConversationSnapshot(
|
||||
blocks: interrupted
|
||||
? interruptActiveToolBlocks(metadata.blocks)
|
||||
: metadata.blocks,
|
||||
displayCaptureTruncated:
|
||||
metadata.displayCaptureTruncated,
|
||||
createdAt:
|
||||
metadata.createdAt ?? Date.parse(message.created_at),
|
||||
state: interrupted ? ('error' as const) : message.state,
|
||||
@@ -1192,9 +1194,6 @@ function appendRecoveredMessageBlock(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (current.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
current.push({
|
||||
id: randomUUID(),
|
||||
type,
|
||||
@@ -1225,9 +1224,6 @@ function upsertRecoveredToolBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{ id: randomUUID(), type: 'tool' as const, tool }
|
||||
@@ -1267,9 +1263,6 @@ function upsertRecoveredSubagentBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{ id: randomUUID(), type: 'subagent' as const, childTaskId }
|
||||
@@ -1302,16 +1295,6 @@ function failedToolRepresentsError(
|
||||
)
|
||||
}
|
||||
|
||||
function canRetainRecoveredActivity(
|
||||
message: ConversationMessage
|
||||
): boolean {
|
||||
return (
|
||||
(message.tools?.length ?? 0) +
|
||||
(message.subagents?.length ?? 0) <
|
||||
maximumConversationRetainedActivities
|
||||
)
|
||||
}
|
||||
|
||||
function reduceRecoveredAgentEvent(
|
||||
message: ConversationMessage,
|
||||
event: RemoteConversationTaskEventInput['event']
|
||||
@@ -1332,9 +1315,6 @@ function reduceRecoveredAgentEvent(
|
||||
...message,
|
||||
content: `${message.content}${delta}`,
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(delta && message.blocks && blocks === message.blocks),
|
||||
status:
|
||||
event.delta.length > remaining
|
||||
? '回答过长,已在本地截断显示'
|
||||
@@ -1355,9 +1335,6 @@ function reduceRecoveredAgentEvent(
|
||||
...message,
|
||||
reasoning: `${reasoning}${delta}`,
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(delta && message.blocks && blocks === message.blocks),
|
||||
status: undefined
|
||||
}
|
||||
} else if (event.type === 'status') {
|
||||
@@ -1405,25 +1382,14 @@ function reduceRecoveredAgentEvent(
|
||||
)
|
||||
if (index >= 0) {
|
||||
tools[index] = tool
|
||||
} else if (!canRetainRecoveredActivity(message)) {
|
||||
next = {
|
||||
...message,
|
||||
displayCaptureTruncated: true
|
||||
}
|
||||
}
|
||||
if (index < 0 && next === message) {
|
||||
} else {
|
||||
tools.push(tool)
|
||||
}
|
||||
if (next === message) {
|
||||
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
|
||||
next = {
|
||||
...message,
|
||||
tools,
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(message.blocks && blocks === message.blocks)
|
||||
}
|
||||
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
|
||||
next = {
|
||||
...message,
|
||||
tools,
|
||||
blocks
|
||||
}
|
||||
} else if (event.type === 'subagent') {
|
||||
const commonSubagent = {
|
||||
@@ -1448,49 +1414,25 @@ function reduceRecoveredAgentEvent(
|
||||
const index = subagents.findIndex(
|
||||
(candidate) => candidate.childTaskId === event.childTaskId
|
||||
)
|
||||
let retainSubagent = true
|
||||
if (index >= 0) {
|
||||
subagents[index] = subagent
|
||||
} else {
|
||||
const replacesTool = Boolean(
|
||||
event.runtimeCallId &&
|
||||
message.tools?.some(
|
||||
(tool) => tool.callId === event.runtimeCallId
|
||||
)
|
||||
)
|
||||
if (
|
||||
!replacesTool &&
|
||||
!canRetainRecoveredActivity(message)
|
||||
) {
|
||||
retainSubagent = false
|
||||
} else {
|
||||
subagents.push(subagent)
|
||||
}
|
||||
subagents.push(subagent)
|
||||
}
|
||||
if (!retainSubagent) {
|
||||
next = {
|
||||
...message,
|
||||
displayCaptureTruncated: true
|
||||
}
|
||||
} else {
|
||||
const blocks = upsertRecoveredSubagentBlock(
|
||||
message.blocks,
|
||||
event.childTaskId,
|
||||
event.runtimeCallId
|
||||
)
|
||||
next = {
|
||||
...message,
|
||||
subagents,
|
||||
tools: event.runtimeCallId
|
||||
? message.tools?.filter(
|
||||
(tool) => tool.callId !== event.runtimeCallId
|
||||
)
|
||||
: message.tools,
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(message.blocks && blocks === message.blocks)
|
||||
}
|
||||
const blocks = upsertRecoveredSubagentBlock(
|
||||
message.blocks,
|
||||
event.childTaskId,
|
||||
event.runtimeCallId
|
||||
)
|
||||
next = {
|
||||
...message,
|
||||
subagents,
|
||||
tools: event.runtimeCallId
|
||||
? message.tools?.filter(
|
||||
(tool) => tool.callId !== event.runtimeCallId
|
||||
)
|
||||
: message.tools,
|
||||
blocks
|
||||
}
|
||||
} else if (event.type === 'artifact') {
|
||||
next = {
|
||||
@@ -2087,6 +2029,11 @@ export class AssistantDatabase {
|
||||
]) {
|
||||
database.exec(`DELETE FROM ${table}`)
|
||||
}
|
||||
database.exec(
|
||||
`UPDATE activity_history
|
||||
SET records_json = '[]', legacy_history_may_be_incomplete = 0
|
||||
WHERE singleton = 1`
|
||||
)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
@@ -4283,6 +4230,40 @@ export class AssistantDatabase {
|
||||
return rows.map(toTask)
|
||||
}
|
||||
|
||||
getActivityHistory(): ActivityHistorySnapshot {
|
||||
const row = this.requireDatabase()
|
||||
.prepare(
|
||||
`SELECT records_json, legacy_history_may_be_incomplete
|
||||
FROM activity_history
|
||||
WHERE singleton = 1`
|
||||
)
|
||||
.get() as
|
||||
| {
|
||||
records_json: string
|
||||
legacy_history_may_be_incomplete: number
|
||||
}
|
||||
| undefined
|
||||
return activityHistorySnapshotSchema.parse({
|
||||
records: row ? JSON.parse(row.records_json) : [],
|
||||
legacyHistoryMayBeIncomplete:
|
||||
row?.legacy_history_may_be_incomplete === 1
|
||||
})
|
||||
}
|
||||
|
||||
replaceActivityHistory(input: ActivityHistorySnapshot): void {
|
||||
const snapshot = activityHistorySnapshotSchema.parse(input)
|
||||
this.requireDatabase()
|
||||
.prepare(
|
||||
`UPDATE activity_history
|
||||
SET records_json = ?, legacy_history_may_be_incomplete = ?
|
||||
WHERE singleton = 1`
|
||||
)
|
||||
.run(
|
||||
JSON.stringify(snapshot.records),
|
||||
Number(snapshot.legacyHistoryMayBeIncomplete)
|
||||
)
|
||||
}
|
||||
|
||||
createTask(input: {
|
||||
id: string
|
||||
projectId?: string
|
||||
@@ -9648,6 +9629,22 @@ export class AssistantDatabase {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (version.user_version < 33) {
|
||||
database.exec(`
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE TABLE IF NOT EXISTS activity_history (
|
||||
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
|
||||
records_json TEXT NOT NULL,
|
||||
legacy_history_may_be_incomplete INTEGER NOT NULL DEFAULT 0
|
||||
CHECK(legacy_history_may_be_incomplete IN (0, 1))
|
||||
);
|
||||
INSERT OR IGNORE INTO activity_history
|
||||
(singleton, records_json, legacy_history_may_be_incomplete)
|
||||
VALUES (1, '[]', 0);
|
||||
PRAGMA user_version = 33;
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
private requireDatabase(): DatabaseSync {
|
||||
|
||||
@@ -95,7 +95,10 @@ describe('workspace file browsing', () => {
|
||||
path: 'remote.md',
|
||||
name: 'remote.md',
|
||||
content: '# fake\n',
|
||||
size: 7
|
||||
size: 7,
|
||||
offsetBytes: 0,
|
||||
bytesRead: 7,
|
||||
truncated: false
|
||||
}))
|
||||
const getChanges = vi.fn(async () => ({
|
||||
rootPath: '/remote',
|
||||
@@ -136,7 +139,9 @@ describe('workspace file browsing', () => {
|
||||
expect(readText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: 'remote.md',
|
||||
maximumBytes: 256 * 1024
|
||||
offsetBytes: 0,
|
||||
maximumBytes: 256 * 1024,
|
||||
allowTruncated: true
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -171,15 +176,11 @@ describe('workspace file browsing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects traversal, unsupported files, invalid UTF-8, and oversized files', async () => {
|
||||
it('rejects traversal, unsupported files, and invalid UTF-8', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
|
||||
temporaryDirectories.push(directory)
|
||||
await writeFile(join(directory, 'image.bin'), Buffer.from([0, 1, 2]))
|
||||
await writeFile(join(directory, 'invalid.txt'), Buffer.from([0xff]))
|
||||
await writeFile(
|
||||
join(directory, 'large.txt'),
|
||||
Buffer.alloc(256 * 1024 + 1, 97)
|
||||
)
|
||||
|
||||
await expect(
|
||||
readWorkspaceFile(directory, '../outside.txt')
|
||||
@@ -190,8 +191,33 @@ describe('workspace file browsing', () => {
|
||||
await expect(
|
||||
readWorkspaceFile(directory, 'invalid.txt')
|
||||
).rejects.toThrow('有效 UTF-8')
|
||||
await expect(
|
||||
readWorkspaceFile(directory, 'large.txt')
|
||||
).rejects.toThrow('超过 256KB')
|
||||
})
|
||||
|
||||
it('reads large UTF-8 files page by page without a total preview limit', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const firstPage = 'a'.repeat(256 * 1024 - 1)
|
||||
const content = `${firstPage}你\n第二页`
|
||||
await writeFile(join(directory, 'large.txt'), content)
|
||||
|
||||
const first = await readWorkspaceFile(directory, 'large.txt')
|
||||
expect(first).toMatchObject({
|
||||
content: firstPage,
|
||||
offsetBytes: 0,
|
||||
nextOffsetBytes: 256 * 1024 - 1,
|
||||
truncated: true
|
||||
})
|
||||
|
||||
const second = await readWorkspaceFile(
|
||||
directory,
|
||||
'large.txt',
|
||||
first.nextOffsetBytes
|
||||
)
|
||||
expect(second).toMatchObject({
|
||||
content: '你\n第二页',
|
||||
offsetBytes: first.nextOffsetBytes,
|
||||
truncated: false
|
||||
})
|
||||
expect(`${first.content}${second.content}`).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../workspace'
|
||||
|
||||
const MAX_DIRECTORY_ENTRIES = 500
|
||||
const MAX_PREVIEW_BYTES = 256 * 1024
|
||||
const PREVIEW_PAGE_BYTES = 256 * 1024
|
||||
const previewExtensions = new Set([
|
||||
'.c',
|
||||
'.cpp',
|
||||
@@ -92,6 +92,7 @@ export class WorkspaceChangesService {
|
||||
|
||||
async readFile(
|
||||
inputPath: string,
|
||||
offsetBytes = 0,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkspaceFilePreview> {
|
||||
const entry = await this.workspace.stat({
|
||||
@@ -110,8 +111,9 @@ export class WorkspaceChangesService {
|
||||
}
|
||||
const preview = await this.workspace.readText({
|
||||
path: inputPath,
|
||||
maximumBytes: MAX_PREVIEW_BYTES,
|
||||
tooLargeMessage: '工作区文件超过 256KB 预览限制',
|
||||
offsetBytes,
|
||||
maximumBytes: PREVIEW_PAGE_BYTES,
|
||||
allowTruncated: true,
|
||||
invalidUtf8Message: '工作区文件不是有效 UTF-8 文本',
|
||||
signal
|
||||
})
|
||||
@@ -119,6 +121,9 @@ export class WorkspaceChangesService {
|
||||
path: preview.path,
|
||||
name: preview.name,
|
||||
content: preview.content,
|
||||
offsetBytes: preview.offsetBytes,
|
||||
nextOffsetBytes: preview.offsetBytes + preview.bytesRead,
|
||||
truncated: preview.truncated,
|
||||
mimeType:
|
||||
extension === '.md' || extension === '.markdown'
|
||||
? 'text/markdown'
|
||||
@@ -177,12 +182,16 @@ export async function listWorkspaceDirectory(
|
||||
|
||||
export async function readWorkspaceFile(
|
||||
workspace: string | WorkspaceAccess,
|
||||
inputPath: string
|
||||
inputPath: string,
|
||||
offsetBytes = 0
|
||||
): Promise<WorkspaceFilePreview> {
|
||||
if (typeof workspace !== 'string') {
|
||||
return new WorkspaceChangesService(workspace).readFile(inputPath)
|
||||
return new WorkspaceChangesService(workspace).readFile(
|
||||
inputPath,
|
||||
offsetBytes
|
||||
)
|
||||
}
|
||||
return withLocalWorkspace(workspace, (access) =>
|
||||
new WorkspaceChangesService(access).readFile(inputPath)
|
||||
new WorkspaceChangesService(access).readFile(inputPath, offsetBytes)
|
||||
)
|
||||
}
|
||||
|
||||
+16
-1
@@ -186,6 +186,7 @@ import {
|
||||
} from '../shared/magic-notes-contracts'
|
||||
import {
|
||||
assistantIdSchema,
|
||||
activityHistorySnapshotSchema,
|
||||
conversationBranchInputSchema,
|
||||
conversationSnapshotsSchema,
|
||||
localConversationSaveBatchSchema,
|
||||
@@ -6598,7 +6599,8 @@ export function registerIpcHandlers(
|
||||
try {
|
||||
return await readWorkspaceFile(
|
||||
executionSpace.workspaceAccess,
|
||||
value.path
|
||||
value.path,
|
||||
value.offsetBytes
|
||||
)
|
||||
} finally {
|
||||
await executionSpace.workspaceAccess.dispose()
|
||||
@@ -6649,6 +6651,19 @@ export function registerIpcHandlers(
|
||||
parsed.status
|
||||
)
|
||||
})
|
||||
registerHandler(ipcChannels.activityHistoryGet, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.getActivityHistory()
|
||||
})
|
||||
registerHandler(
|
||||
ipcChannels.activityHistoryReplace,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
assistantDatabase.replaceActivityHistory(
|
||||
activityHistorySnapshotSchema.parse(input)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(ipcChannels.tokenUsageSummary, (event) => {
|
||||
assertTrustedSender(event, window)
|
||||
|
||||
@@ -67,6 +67,84 @@ export async function readBoundedUtf8File(
|
||||
}
|
||||
}
|
||||
|
||||
export async function readUtf8FileRange(
|
||||
filePath: string,
|
||||
offsetBytes: number,
|
||||
maximumBytes: number,
|
||||
invalidUtf8Message: string
|
||||
): Promise<{
|
||||
content: string
|
||||
size: number
|
||||
offsetBytes: number
|
||||
bytesRead: number
|
||||
truncated: boolean
|
||||
}> {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
const metadata = await handle.stat()
|
||||
if (
|
||||
!metadata.isFile() ||
|
||||
!Number.isSafeInteger(offsetBytes) ||
|
||||
offsetBytes < 0 ||
|
||||
offsetBytes > metadata.size
|
||||
) {
|
||||
throw new Error('工作区读取范围无效')
|
||||
}
|
||||
const requestedBytes = Math.min(
|
||||
maximumBytes,
|
||||
metadata.size - offsetBytes
|
||||
)
|
||||
const data = Buffer.alloc(requestedBytes)
|
||||
let bytesRead = 0
|
||||
while (bytesRead < data.length) {
|
||||
const result = await handle.read(
|
||||
data,
|
||||
bytesRead,
|
||||
data.length - bytesRead,
|
||||
offsetBytes + bytesRead
|
||||
)
|
||||
if (result.bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
bytesRead += result.bytesRead
|
||||
}
|
||||
const selected = data.subarray(0, bytesRead)
|
||||
const reachesEnd = offsetBytes + bytesRead >= metadata.size
|
||||
let decoded: string | undefined
|
||||
let decodedBytes = bytesRead
|
||||
for (
|
||||
let trailingBytes = 0;
|
||||
trailingBytes <= (reachesEnd ? 0 : 3);
|
||||
trailingBytes += 1
|
||||
) {
|
||||
decodedBytes = bytesRead - trailingBytes
|
||||
if (decodedBytes < 0) {
|
||||
break
|
||||
}
|
||||
try {
|
||||
decoded = new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
selected.subarray(0, decodedBytes)
|
||||
)
|
||||
break
|
||||
} catch {
|
||||
// A page may end in the middle of one UTF-8 character.
|
||||
}
|
||||
}
|
||||
if (decoded === undefined) {
|
||||
throw new Error(invalidUtf8Message)
|
||||
}
|
||||
return {
|
||||
content: decoded,
|
||||
size: metadata.size,
|
||||
offsetBytes,
|
||||
bytesRead: decodedBytes,
|
||||
truncated: offsetBytes + decodedBytes < metadata.size
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function readBoundedFile(
|
||||
filePath: string,
|
||||
maximumBytes: number,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
isPathInside,
|
||||
listBoundedDirectoryEntries,
|
||||
readBoundedUtf8File,
|
||||
readUtf8FileRange,
|
||||
resolveExistingWorkspacePath
|
||||
} from '../workspace-file-access'
|
||||
import type {
|
||||
@@ -562,6 +563,30 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
|
||||
'工作区读取上限无效'
|
||||
)
|
||||
const file = await this.resolveExisting(input.path, 'file')
|
||||
const offsetBytes = input.offsetBytes ?? 0
|
||||
if (input.allowTruncated || offsetBytes > 0) {
|
||||
const preview = await readUtf8FileRange(
|
||||
file.canonicalPath,
|
||||
offsetBytes,
|
||||
maximumBytes,
|
||||
input.invalidUtf8Message ??
|
||||
'工作区读取目标不是有效 UTF-8 文本'
|
||||
)
|
||||
if (preview.truncated && !input.allowTruncated) {
|
||||
throw new Error(
|
||||
input.tooLargeMessage ?? '工作区文本文件超过安全限制'
|
||||
)
|
||||
}
|
||||
if (preview.truncated && preview.bytesRead === 0) {
|
||||
throw new Error('工作区读取分页无法继续')
|
||||
}
|
||||
input.signal?.throwIfAborted()
|
||||
return {
|
||||
path: file.path,
|
||||
name: basename(file.canonicalPath),
|
||||
...preview
|
||||
}
|
||||
}
|
||||
const preview = await readBoundedUtf8File(
|
||||
file.canonicalPath,
|
||||
maximumBytes,
|
||||
@@ -573,7 +598,10 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
|
||||
path: file.path,
|
||||
name: basename(file.canonicalPath),
|
||||
content: preview.content,
|
||||
size: preview.size
|
||||
size: preview.size,
|
||||
offsetBytes: 0,
|
||||
bytesRead: preview.size,
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,10 @@ describe('RemoteWorkspaceAccess', () => {
|
||||
path: 'README.md',
|
||||
name: 'README.md',
|
||||
content: 'hello',
|
||||
size: 5
|
||||
size: 5,
|
||||
offsetBytes: 0,
|
||||
bytesRead: 5,
|
||||
truncated: false
|
||||
})
|
||||
await expect(
|
||||
access.search({
|
||||
@@ -365,6 +368,70 @@ describe('RemoteWorkspaceAccess', () => {
|
||||
expect(transport.acquireLease).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns remote file pages and keeps UTF-8 boundaries intact', async () => {
|
||||
const invalidBoundary = Object.assign(
|
||||
new Error('Workspace file is not valid UTF-8'),
|
||||
{ data: { code: 'invalid-utf8' } }
|
||||
)
|
||||
const readWorkspaceText = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(invalidBoundary)
|
||||
.mockResolvedValueOnce({
|
||||
relativePath: 'large.txt',
|
||||
content: '你a',
|
||||
offsetBytes: 0,
|
||||
bytesRead: 4,
|
||||
totalBytes: 7,
|
||||
digest,
|
||||
truncated: true
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
relativePath: 'large.txt',
|
||||
content: '好',
|
||||
offsetBytes: 4,
|
||||
bytesRead: 3,
|
||||
totalBytes: 7,
|
||||
digest,
|
||||
truncated: false
|
||||
})
|
||||
const lease = createLease({ readWorkspaceText })
|
||||
const { access } = createAccess(lease)
|
||||
|
||||
const first = await access.readText({
|
||||
path: 'large.txt',
|
||||
maximumBytes: 5,
|
||||
allowTruncated: true
|
||||
})
|
||||
const second = await access.readText({
|
||||
path: 'large.txt',
|
||||
offsetBytes: first.offsetBytes + first.bytesRead,
|
||||
maximumBytes: 5,
|
||||
allowTruncated: true
|
||||
})
|
||||
|
||||
expect(first).toMatchObject({
|
||||
content: '你a',
|
||||
offsetBytes: 0,
|
||||
bytesRead: 4,
|
||||
truncated: true
|
||||
})
|
||||
expect(second).toMatchObject({
|
||||
content: '好',
|
||||
offsetBytes: 4,
|
||||
truncated: false
|
||||
})
|
||||
expect(
|
||||
readWorkspaceText.mock.calls.map(([request]) => ({
|
||||
offsetBytes: request.offsetBytes,
|
||||
maximumBytes: request.maximumBytes
|
||||
}))
|
||||
).toEqual([
|
||||
{ offsetBytes: 0, maximumBytes: 5 },
|
||||
{ offsetBytes: 0, maximumBytes: 4 },
|
||||
{ offsetBytes: 4, maximumBytes: 5 }
|
||||
])
|
||||
})
|
||||
|
||||
it('retries one inconsistent Git snapshot and maps status and diff', async () => {
|
||||
const getGitStatus = vi
|
||||
.fn()
|
||||
|
||||
@@ -149,6 +149,20 @@ function boundedInteger(
|
||||
return resolved
|
||||
}
|
||||
|
||||
function isInvalidUtf8RpcError(error: unknown): boolean {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('data' in error) ||
|
||||
!error.data ||
|
||||
typeof error.data !== 'object' ||
|
||||
!('code' in error.data)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return error.data.code === 'invalid-utf8'
|
||||
}
|
||||
|
||||
function assertTransportBinding(
|
||||
expected: RemoteWorkspaceProjectBinding,
|
||||
actual: RemoteWorkspaceTransportBinding
|
||||
@@ -419,16 +433,43 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
'工作区读取上限无效'
|
||||
)
|
||||
const opened = await this.open(input.signal)
|
||||
const request = remoteWorkspaceReadTextRequestSchema.parse({
|
||||
...this.reference(opened),
|
||||
relativePath: input.path,
|
||||
offsetBytes: 0,
|
||||
maximumBytes
|
||||
})
|
||||
const rawResult = await opened.lease.readWorkspaceText(
|
||||
request,
|
||||
input.signal
|
||||
)
|
||||
const offsetBytes = input.offsetBytes ?? 0
|
||||
let rawResult: RemoteWorkspaceReadTextResult | undefined
|
||||
let boundaryError: unknown
|
||||
const maximumTrailingBytes = input.allowTruncated
|
||||
? Math.min(3, maximumBytes - 1)
|
||||
: 0
|
||||
for (
|
||||
let trailingBytes = 0;
|
||||
trailingBytes <= maximumTrailingBytes;
|
||||
trailingBytes += 1
|
||||
) {
|
||||
const request = remoteWorkspaceReadTextRequestSchema.parse({
|
||||
...this.reference(opened),
|
||||
relativePath: input.path,
|
||||
offsetBytes,
|
||||
maximumBytes: maximumBytes - trailingBytes
|
||||
})
|
||||
try {
|
||||
rawResult = await opened.lease.readWorkspaceText(
|
||||
request,
|
||||
input.signal
|
||||
)
|
||||
break
|
||||
} catch (error) {
|
||||
if (
|
||||
!input.allowTruncated ||
|
||||
trailingBytes === maximumTrailingBytes ||
|
||||
!isInvalidUtf8RpcError(error)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
boundaryError = error
|
||||
}
|
||||
}
|
||||
if (!rawResult) {
|
||||
throw boundaryError
|
||||
}
|
||||
let result: RemoteWorkspaceReadTextResult
|
||||
try {
|
||||
result = remoteWorkspaceReadTextResultSchema.parse(rawResult)
|
||||
@@ -444,21 +485,32 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
|
||||
}
|
||||
if (
|
||||
result.relativePath !== input.path ||
|
||||
result.offsetBytes !== 0
|
||||
result.offsetBytes !== offsetBytes ||
|
||||
result.offsetBytes + result.bytesRead > result.totalBytes ||
|
||||
(!result.truncated &&
|
||||
result.offsetBytes + result.bytesRead !== result.totalBytes) ||
|
||||
(result.truncated &&
|
||||
result.offsetBytes + result.bytesRead >= result.totalBytes)
|
||||
) {
|
||||
throw new Error('远程工作区返回了不匹配的文件')
|
||||
}
|
||||
if (result.truncated || result.bytesRead !== result.totalBytes) {
|
||||
if (result.truncated && !input.allowTruncated) {
|
||||
throw new Error(
|
||||
input.tooLargeMessage ?? '工作区文本文件超过安全限制'
|
||||
)
|
||||
}
|
||||
if (result.truncated && result.bytesRead === 0) {
|
||||
throw new Error('远程工作区读取分页无法继续')
|
||||
}
|
||||
const name = input.path.split('/').at(-1) ?? input.path
|
||||
return {
|
||||
path: input.path,
|
||||
name,
|
||||
content: result.content,
|
||||
size: result.totalBytes
|
||||
size: result.totalBytes,
|
||||
offsetBytes: result.offsetBytes,
|
||||
bytesRead: result.bytesRead,
|
||||
truncated: result.truncated
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ export type WorkspaceEntry = WorkspaceDirectoryEntry & {
|
||||
}
|
||||
|
||||
export type WorkspaceReadInput = WorkspacePathInput & {
|
||||
offsetBytes?: number
|
||||
maximumBytes?: number
|
||||
allowTruncated?: boolean
|
||||
tooLargeMessage?: string
|
||||
invalidUtf8Message?: string
|
||||
}
|
||||
@@ -49,6 +51,9 @@ export type FilePreview = {
|
||||
name: string
|
||||
content: string
|
||||
size: number
|
||||
offsetBytes: number
|
||||
bytesRead: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type WorkspaceWriteInput = WorkspacePathInput & {
|
||||
|
||||
+24
-2
@@ -49,6 +49,8 @@ import type {
|
||||
AssistantHeartbeatRun,
|
||||
AssistantExpert,
|
||||
AssistantTask,
|
||||
ActivityHistorySnapshot,
|
||||
ActivityRecord,
|
||||
TokenUsageSummary,
|
||||
ConversationBranchInput,
|
||||
ConversationSnapshot,
|
||||
@@ -1096,10 +1098,15 @@ const desktopApi: DesktopApi = {
|
||||
projectId,
|
||||
path
|
||||
}) as Promise<WorkspaceDirectoryListing>,
|
||||
readFile: (projectId: string, path: string) =>
|
||||
readFile: (
|
||||
projectId: string,
|
||||
path: string,
|
||||
offsetBytes?: number
|
||||
) =>
|
||||
ipcRenderer.invoke(ipcChannels.workspaceFileRead, {
|
||||
projectId,
|
||||
path
|
||||
path,
|
||||
offsetBytes
|
||||
}) as Promise<WorkspaceFilePreview>,
|
||||
openPath: async (
|
||||
projectId: string,
|
||||
@@ -1126,6 +1133,21 @@ const desktopApi: DesktopApi = {
|
||||
})
|
||||
}
|
||||
},
|
||||
activityHistory: {
|
||||
get: () =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.activityHistoryGet
|
||||
) as Promise<ActivityHistorySnapshot>,
|
||||
replace: async (
|
||||
records: ActivityRecord[],
|
||||
legacyHistoryMayBeIncomplete: boolean
|
||||
) => {
|
||||
await ipcRenderer.invoke(ipcChannels.activityHistoryReplace, {
|
||||
records,
|
||||
legacyHistoryMayBeIncomplete
|
||||
})
|
||||
}
|
||||
},
|
||||
usage: {
|
||||
getTokenSummary: () =>
|
||||
ipcRenderer.invoke(
|
||||
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
builtInDefaultProjectSeedName
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { ActivityPanel } from './ActivityPanel'
|
||||
import {
|
||||
MAX_ACTIVITY_RECORDS,
|
||||
type ActivityRecord
|
||||
} from './activity-store'
|
||||
import { type ActivityRecord } from './activity-store'
|
||||
import i18n from './i18n'
|
||||
|
||||
function makeRecord(
|
||||
@@ -642,9 +639,9 @@ describe('ActivityPanel', () => {
|
||||
expect(within(detail).getByText('项目:项目甲')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('never renders more than 500 records', () => {
|
||||
it('reveals retained records beyond the initial render batch', () => {
|
||||
const records = Array.from(
|
||||
{ length: MAX_ACTIVITY_RECORDS + 1 },
|
||||
{ length: 501 },
|
||||
(_, index) => makeRecord(index)
|
||||
)
|
||||
render(
|
||||
@@ -658,6 +655,30 @@ describe('ActivityPanel', () => {
|
||||
|
||||
expect(screen.getByText('活动 499')).toBeInTheDocument()
|
||||
expect(screen.queryByText('活动 500')).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '继续显示(剩余 1 条)'
|
||||
})
|
||||
)
|
||||
expect(screen.getByText('活动 500')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('warns when the migrated legacy history may already be incomplete', () => {
|
||||
render(
|
||||
<ActivityPanel
|
||||
legacyHistoryMayBeIncomplete
|
||||
onClear={vi.fn()}
|
||||
onOpenConversation={vi.fn()}
|
||||
records={[makeRecord(1)]}
|
||||
tokenUsage={makeTokenUsage()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。'
|
||||
)
|
||||
).toHaveAttribute('role', 'status')
|
||||
})
|
||||
|
||||
it('shows totals without double-counting cache tokens', () => {
|
||||
|
||||
@@ -6,10 +6,7 @@ import type {
|
||||
TokenUsageSummary
|
||||
} from '../../shared/assistant-contracts'
|
||||
import { isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts'
|
||||
import {
|
||||
MAX_ACTIVITY_RECORDS,
|
||||
type ActivityRecord
|
||||
} from './activity-store'
|
||||
import { type ActivityRecord } from './activity-store'
|
||||
import {
|
||||
getTokenUsageTotals,
|
||||
groupTokenUsage,
|
||||
@@ -35,7 +32,10 @@ type ActivityActorKind =
|
||||
| 'tool'
|
||||
| 'approval'
|
||||
|
||||
const activityRenderBatchSize = 500
|
||||
|
||||
export type ActivityPanelProps = {
|
||||
legacyHistoryMayBeIncomplete?: boolean
|
||||
projects?: readonly AssistantProject[]
|
||||
records: readonly ActivityRecord[]
|
||||
tokenUsage: TokenUsageSummary
|
||||
@@ -240,6 +240,7 @@ function groupActivityRecordsByProject(
|
||||
}
|
||||
|
||||
export function ActivityPanel({
|
||||
legacyHistoryMayBeIncomplete = false,
|
||||
projects = [],
|
||||
records,
|
||||
tokenUsage,
|
||||
@@ -256,6 +257,9 @@ export function ActivityPanel({
|
||||
const [confirmingClear, setConfirmingClear] = useState(false)
|
||||
const [selectedTimelineRecordId, setSelectedTimelineRecordId] =
|
||||
useState<string>()
|
||||
const [visibleRecordCount, setVisibleRecordCount] = useState(
|
||||
activityRenderBatchSize
|
||||
)
|
||||
const dateTimeFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(i18n.resolvedLanguage || 'zh-CN', {
|
||||
@@ -378,13 +382,13 @@ export function ActivityPanel({
|
||||
}),
|
||||
[projectDisplayNames, tokenUsage]
|
||||
)
|
||||
const visibleRecords = useMemo(
|
||||
() => displayRecords.slice(0, MAX_ACTIVITY_RECORDS),
|
||||
[displayRecords]
|
||||
const matchingRecords = useMemo(
|
||||
() => displayRecords.filter((record) => matchesFilter(record, filter)),
|
||||
[displayRecords, filter]
|
||||
)
|
||||
const filteredRecords = useMemo(
|
||||
() => visibleRecords.filter((record) => matchesFilter(record, filter)),
|
||||
[filter, visibleRecords]
|
||||
() => matchingRecords.slice(0, visibleRecordCount),
|
||||
[matchingRecords, visibleRecordCount]
|
||||
)
|
||||
const projectGroups = useMemo(
|
||||
() => groupActivityRecordsByProject(filteredRecords, displayRecords),
|
||||
@@ -418,8 +422,8 @@ export function ActivityPanel({
|
||||
() => getConversationTitles(displayRecords),
|
||||
[displayRecords]
|
||||
)
|
||||
const activeCount = visibleRecords.filter(isActive).length
|
||||
const failedCount = visibleRecords.filter(isFailed).length
|
||||
const activeCount = displayRecords.filter(isActive).length
|
||||
const failedCount = displayRecords.filter(isFailed).length
|
||||
const filters: ReadonlyArray<{
|
||||
value: ActivityFilter
|
||||
label: string
|
||||
@@ -427,8 +431,8 @@ export function ActivityPanel({
|
||||
{
|
||||
value: 'all',
|
||||
label: t('filters.all', {
|
||||
count: visibleRecords.length,
|
||||
formattedCount: formatCount(visibleRecords.length)
|
||||
count: displayRecords.length,
|
||||
formattedCount: formatCount(displayRecords.length)
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -620,19 +624,19 @@ export function ActivityPanel({
|
||||
/>
|
||||
<DestructiveConfirmActions
|
||||
confirmAriaLabel={t('clear.confirmAriaLabel', {
|
||||
count: visibleRecords.length,
|
||||
formattedCount: formatCount(visibleRecords.length)
|
||||
count: displayRecords.length,
|
||||
formattedCount: formatCount(displayRecords.length)
|
||||
})}
|
||||
confirmLabel={t('clear.confirmLabel', {
|
||||
count: visibleRecords.length,
|
||||
formattedCount: formatCount(visibleRecords.length)
|
||||
count: displayRecords.length,
|
||||
formattedCount: formatCount(displayRecords.length)
|
||||
})}
|
||||
confirming={confirmingClear}
|
||||
disabled={!confirmingClear && visibleRecords.length === 0}
|
||||
disabled={!confirmingClear && displayRecords.length === 0}
|
||||
icon={<Trash2 aria-hidden="true" size={15} />}
|
||||
message={t('clear.message', {
|
||||
count: visibleRecords.length,
|
||||
formattedCount: formatCount(visibleRecords.length)
|
||||
count: displayRecords.length,
|
||||
formattedCount: formatCount(displayRecords.length)
|
||||
})}
|
||||
onCancel={() => setConfirmingClear(false)}
|
||||
onConfirm={() => {
|
||||
@@ -667,6 +671,24 @@ export function ActivityPanel({
|
||||
}
|
||||
/>
|
||||
)
|
||||
const loadMoreRecords =
|
||||
filteredRecords.length < matchingRecords.length ? (
|
||||
<button
|
||||
className="secondary-button activity-panel__load-more"
|
||||
onClick={() =>
|
||||
setVisibleRecordCount(
|
||||
(current) => current + activityRenderBatchSize
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('records.loadMore', {
|
||||
remaining: formatCount(
|
||||
matchingRecords.length - filteredRecords.length
|
||||
)
|
||||
})}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -680,6 +702,11 @@ export function ActivityPanel({
|
||||
scope={{ kind: 'all-projects' }}
|
||||
title={t('header.title')}
|
||||
/>
|
||||
{legacyHistoryMayBeIncomplete && (
|
||||
<p className="activity-panel__legacy-warning" role="status">
|
||||
{t('records.legacyHistoryMayBeIncomplete')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PageTabs
|
||||
ariaLabel={t('tabs.ariaLabel')}
|
||||
@@ -781,6 +808,7 @@ export function ActivityPanel({
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{loadMoreRecords}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -957,6 +985,7 @@ export function ActivityPanel({
|
||||
{renderRecordCard(selectedTimelineRecord, true)}
|
||||
</aside>
|
||||
)}
|
||||
{loadMoreRecords}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+107
-16
@@ -112,7 +112,7 @@ vi.mock('./TerminalPanel', () => ({
|
||||
}))
|
||||
|
||||
import App from './App'
|
||||
import { loadActivityRecords } from './activity-store'
|
||||
import { ACTIVITY_STORAGE_KEY } from './activity-store'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
|
||||
|
||||
@@ -541,7 +541,10 @@ const api: DesktopApi = {
|
||||
name: path.split('/').at(-1) ?? path,
|
||||
content: '',
|
||||
mimeType: 'text/plain' as const,
|
||||
size: 0
|
||||
size: 0,
|
||||
offsetBytes: 0,
|
||||
nextOffsetBytes: 0,
|
||||
truncated: false
|
||||
})),
|
||||
openPath: vi.fn(async () => {})
|
||||
},
|
||||
@@ -549,6 +552,13 @@ const api: DesktopApi = {
|
||||
list: vi.fn(async () => []),
|
||||
setStatus: vi.fn(async () => {})
|
||||
},
|
||||
activityHistory: {
|
||||
get: vi.fn(async () => ({
|
||||
records: [],
|
||||
legacyHistoryMayBeIncomplete: false
|
||||
})),
|
||||
replace: vi.fn(async () => {})
|
||||
},
|
||||
usage: {
|
||||
getTokenSummary: vi.fn(async () => ({
|
||||
totals: {
|
||||
@@ -1137,6 +1147,15 @@ describe('App', () => {
|
||||
}
|
||||
})
|
||||
vi.mocked(api.tasks.list).mockReset().mockResolvedValue([])
|
||||
vi.mocked(api.activityHistory.get)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
records: [],
|
||||
legacyHistoryMayBeIncomplete: false
|
||||
})
|
||||
vi.mocked(api.activityHistory.replace)
|
||||
.mockReset()
|
||||
.mockResolvedValue()
|
||||
vi.mocked(api.schedules.list).mockReset().mockResolvedValue([])
|
||||
api.channels = undefined
|
||||
api.updates = undefined
|
||||
@@ -3694,7 +3713,7 @@ describe('App', () => {
|
||||
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
|
||||
})
|
||||
|
||||
it('keeps a bounded display copy without stopping a long tool chain', async () => {
|
||||
it('keeps every activity detail in a long tool chain', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
|
||||
@@ -3713,21 +3732,38 @@ describe('App', () => {
|
||||
requestId: request.requestId,
|
||||
type: 'tool',
|
||||
callId: `call-${index + 1}`,
|
||||
name: 'read',
|
||||
name: index === 500 ? `read${'长'.repeat(300)}` : 'read',
|
||||
state: 'completed',
|
||||
summary: `OpenCode 工具:read ${index + 1}`
|
||||
summary:
|
||||
index === 500
|
||||
? `OpenCode 工具:read 501 ${'详情'.repeat(2_100)}`
|
||||
: `OpenCode 工具:read ${index + 1}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('region', { name: '工具执行,共 500 项' })
|
||||
screen.getByRole('region', { name: '工具执行,共 501 项' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
screen.queryByText(
|
||||
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
).not.toBeInTheDocument()
|
||||
await waitFor(
|
||||
() => {
|
||||
const saved =
|
||||
vi.mocked(api.activityHistory.replace).mock.calls.at(-1)?.[0] ??
|
||||
[]
|
||||
expect(
|
||||
saved.filter((record) => record.kind === 'tool')
|
||||
).toHaveLength(501)
|
||||
const lastTool = saved.find((record) => record.callId === 'call-501')
|
||||
expect(lastTool?.detail.length).toBeGreaterThan(4_000)
|
||||
expect(lastTool?.title).toHaveLength(240)
|
||||
},
|
||||
{ timeout: 3_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps adjacent streamed text blocks exact in StrictMode', async () => {
|
||||
@@ -5593,7 +5629,10 @@ describe('App', () => {
|
||||
name: 'README.md',
|
||||
content: '# 工作区说明',
|
||||
mimeType: 'text/markdown',
|
||||
size: 19
|
||||
size: 19,
|
||||
offsetBytes: 0,
|
||||
nextOffsetBytes: 19,
|
||||
truncated: false
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
@@ -5614,7 +5653,8 @@ describe('App', () => {
|
||||
).not.toBeInTheDocument()
|
||||
expect(api.workspace.readFile).toHaveBeenCalledWith(
|
||||
projectId,
|
||||
'README.md'
|
||||
'README.md',
|
||||
0
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '返回工作区' })
|
||||
@@ -10645,11 +10685,14 @@ describe('App', () => {
|
||||
fireEvent.click(screen.getByLabelText('发送'))
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
loadActivityRecords().find(
|
||||
(record) =>
|
||||
record.kind === 'request' &&
|
||||
record.title === '记录项目范围'
|
||||
)?.scope
|
||||
vi
|
||||
.mocked(api.activityHistory.replace)
|
||||
.mock.calls.at(-1)?.[0]
|
||||
.find(
|
||||
(record) =>
|
||||
record.kind === 'request' &&
|
||||
record.title === '记录项目范围'
|
||||
)?.scope
|
||||
).toEqual({
|
||||
kind: 'project',
|
||||
projectId,
|
||||
@@ -10658,6 +10701,50 @@ describe('App', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('migrates legacy activity and keeps its possible-loss warning visible', async () => {
|
||||
localStorage.setItem(
|
||||
ACTIVITY_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'legacy-activity',
|
||||
conversationId: 'legacy-conversation',
|
||||
requestId: 'legacy-request',
|
||||
scope: { kind: 'global' },
|
||||
kind: 'tool',
|
||||
title: '旧版工具记录',
|
||||
detail: 'x'.repeat(4_000),
|
||||
status: 'completed',
|
||||
createdAt: 1
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
render(<App />)
|
||||
await screen.findByText('项目:默认项目')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.activityHistory.replace).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: 'legacy-activity',
|
||||
detail: 'x'.repeat(4_000)
|
||||
})
|
||||
],
|
||||
true
|
||||
)
|
||||
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '运行记录' }))
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。',
|
||||
{},
|
||||
{ timeout: 3_000 }
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks the current primary navigation page and hides decorative icons', async () => {
|
||||
render(<App />)
|
||||
|
||||
@@ -10896,7 +10983,11 @@ describe('App', () => {
|
||||
fireEvent.click(magicNotesEntry)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: '魔法笔记' })
|
||||
await screen.findByRole(
|
||||
'heading',
|
||||
{ name: '魔法笔记' },
|
||||
{ timeout: 3000 }
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('全局')).toBeInTheDocument()
|
||||
expect(
|
||||
|
||||
+138
-84
@@ -94,6 +94,7 @@ import {
|
||||
getRuntimeSelectionForProvider
|
||||
} from './runtime-selection'
|
||||
import type {
|
||||
ActivityHistorySnapshot,
|
||||
AssistantProject,
|
||||
AssistantArtifact,
|
||||
AssistantMemory,
|
||||
@@ -128,8 +129,6 @@ import {
|
||||
conversationMessageBlocksSchema,
|
||||
conversationSubagentActivitySchema,
|
||||
interactiveWorkModes,
|
||||
maximumConversationMessageBlocks,
|
||||
maximumConversationRetainedActivities,
|
||||
normalizeInteractiveWorkMode,
|
||||
projectChannelLabels
|
||||
} from '../../shared/assistant-contracts'
|
||||
@@ -141,9 +140,10 @@ import {
|
||||
type ToolActivity
|
||||
} from './ChatTimeline'
|
||||
import {
|
||||
loadActivityRecords,
|
||||
clearLegacyActivityHistory,
|
||||
loadLegacyActivityHistory,
|
||||
mergeActivityRecords,
|
||||
reconcileActivityRecords,
|
||||
saveActivityRecords,
|
||||
upsertActivityRecord,
|
||||
type ActivityRecord
|
||||
} from './activity-store'
|
||||
@@ -638,9 +638,6 @@ function appendMessageContentBlock(
|
||||
}
|
||||
return current
|
||||
}
|
||||
if (current.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
current.push({
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
@@ -670,9 +667,6 @@ function upsertMessageToolBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
@@ -718,9 +712,6 @@ function upsertMessageSubagentBlock(
|
||||
: block
|
||||
)
|
||||
}
|
||||
if (blocks.length >= maximumConversationMessageBlocks) {
|
||||
return blocks
|
||||
}
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
@@ -749,14 +740,6 @@ function terminalizeMessageToolBlocks(
|
||||
)
|
||||
}
|
||||
|
||||
function canRetainMessageActivity(message: Message): boolean {
|
||||
return (
|
||||
(message.tools?.length ?? 0) +
|
||||
(message.subagents?.length ?? 0) <
|
||||
maximumConversationRetainedActivities
|
||||
)
|
||||
}
|
||||
|
||||
function isErrorRepresentedByFailedTool(
|
||||
tools: ToolActivity[] | undefined,
|
||||
errorMessage: string
|
||||
@@ -2688,10 +2671,19 @@ function App(): React.JSX.Element {
|
||||
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false)
|
||||
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null)
|
||||
const [legacyActivityHistory] = useState(loadLegacyActivityHistory)
|
||||
const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
|
||||
loadActivityRecords
|
||||
legacyActivityHistory.records
|
||||
)
|
||||
const [
|
||||
legacyActivityHistoryMayBeIncomplete,
|
||||
setLegacyActivityHistoryMayBeIncomplete
|
||||
] = useState(legacyActivityHistory.historyMayBeIncomplete)
|
||||
const [activityHistoryReady, setActivityHistoryReady] = useState(false)
|
||||
const activityRecordsRef = useRef(activityRecords)
|
||||
const legacyActivityHistoryMayBeIncompleteRef = useRef(
|
||||
legacyActivityHistoryMayBeIncomplete
|
||||
)
|
||||
const activeRuns = useRef(new Map<string, ActiveRun>())
|
||||
const preparingConversations = useRef(new Set<string>())
|
||||
const [activeConversationIds, setActiveConversationIds] = useState<
|
||||
@@ -3940,6 +3932,7 @@ function App(): React.JSX.Element {
|
||||
setActivityRecords((current) =>
|
||||
upsertActivityRecord(current, {
|
||||
...record,
|
||||
title: record.title.slice(0, 240),
|
||||
scope,
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: Date.now()
|
||||
@@ -3961,7 +3954,7 @@ function App(): React.JSX.Element {
|
||||
? {
|
||||
...record,
|
||||
status,
|
||||
detail: detail?.slice(0, 4_000) ?? record.detail
|
||||
detail: detail ?? record.detail
|
||||
}
|
||||
: record
|
||||
)
|
||||
@@ -4398,13 +4391,6 @@ function App(): React.JSX.Element {
|
||||
...message,
|
||||
content: `${message.content}${acceptedDelta}`,
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(
|
||||
acceptedDelta &&
|
||||
message.blocks &&
|
||||
blocks === message.blocks
|
||||
),
|
||||
status:
|
||||
event.delta.length > remaining
|
||||
? tRef.current('chat.status.responseTruncated')
|
||||
@@ -4430,14 +4416,7 @@ function App(): React.JSX.Element {
|
||||
...message,
|
||||
reasoning: `${currentReasoning}${acceptedDelta}`,
|
||||
status: undefined,
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(
|
||||
acceptedDelta &&
|
||||
message.blocks &&
|
||||
blocks === message.blocks
|
||||
)
|
||||
blocks
|
||||
}
|
||||
})
|
||||
} else if (event.type === 'context-metrics') {
|
||||
@@ -4548,8 +4527,7 @@ function App(): React.JSX.Element {
|
||||
title: event.name,
|
||||
detail: [event.summary, event.error]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.slice(0, 4_000),
|
||||
.join('\n'),
|
||||
status:
|
||||
event.state === 'pending'
|
||||
? 'pending'
|
||||
@@ -4575,23 +4553,14 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
if (index >= 0) {
|
||||
tools[index] = tool
|
||||
} else if (!canRetainMessageActivity(message)) {
|
||||
return {
|
||||
...message,
|
||||
displayCaptureTruncated: true
|
||||
}
|
||||
}
|
||||
if (index < 0) {
|
||||
} else {
|
||||
tools.push(tool)
|
||||
}
|
||||
const blocks = upsertMessageToolBlock(message.blocks, tool)
|
||||
return {
|
||||
...message,
|
||||
tools,
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(message.blocks && blocks === message.blocks)
|
||||
blocks
|
||||
}
|
||||
})
|
||||
} else if (event.type === 'subagent') {
|
||||
@@ -4688,8 +4657,7 @@ function App(): React.JSX.Element {
|
||||
event.error
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
.slice(0, 4_000),
|
||||
.join(' · '),
|
||||
status:
|
||||
event.state === 'queued'
|
||||
? 'pending'
|
||||
@@ -4722,21 +4690,6 @@ function App(): React.JSX.Element {
|
||||
if (index >= 0) {
|
||||
subagents[index] = subagent
|
||||
} else {
|
||||
const replacesTool = Boolean(
|
||||
event.runtimeCallId &&
|
||||
message.tools?.some(
|
||||
(tool) => tool.callId === event.runtimeCallId
|
||||
)
|
||||
)
|
||||
if (
|
||||
!replacesTool &&
|
||||
!canRetainMessageActivity(message)
|
||||
) {
|
||||
return {
|
||||
...message,
|
||||
displayCaptureTruncated: true
|
||||
}
|
||||
}
|
||||
subagents.push(subagent)
|
||||
}
|
||||
const runtimeCallId = event.runtimeCallId
|
||||
@@ -4753,10 +4706,7 @@ function App(): React.JSX.Element {
|
||||
(tool) => tool.callId !== runtimeCallId
|
||||
)
|
||||
: message.tools,
|
||||
blocks,
|
||||
displayCaptureTruncated:
|
||||
message.displayCaptureTruncated ||
|
||||
Boolean(message.blocks && blocks === message.blocks)
|
||||
blocks
|
||||
}
|
||||
})
|
||||
} else if (event.type === 'approval') {
|
||||
@@ -4765,7 +4715,7 @@ function App(): React.JSX.Element {
|
||||
requestId: event.requestId,
|
||||
kind: 'approval',
|
||||
title: event.title,
|
||||
detail: event.description.slice(0, 4_000),
|
||||
detail: event.description,
|
||||
status: 'pending'
|
||||
})
|
||||
updateMessage(run.conversationId, run.messageId, (message) => ({
|
||||
@@ -4879,7 +4829,7 @@ function App(): React.JSX.Element {
|
||||
event.type === 'done'
|
||||
? incompleteActivityDetail
|
||||
: event.message
|
||||
}`.slice(0, 4_000)
|
||||
}`
|
||||
}
|
||||
: record
|
||||
)
|
||||
@@ -4894,7 +4844,7 @@ function App(): React.JSX.Element {
|
||||
: tRef.current('chat.status.taskCompleted'),
|
||||
detail:
|
||||
event.type === 'error'
|
||||
? event.message.slice(0, 4_000)
|
||||
? event.message
|
||||
: tRef.current('chat.status.runtimeCompleted'),
|
||||
status: terminalStatus
|
||||
})
|
||||
@@ -5109,10 +5059,28 @@ function App(): React.JSX.Element {
|
||||
persistLocalConversationChanges
|
||||
])
|
||||
|
||||
const persistActivityHistory = useCallback(async (): Promise<void> => {
|
||||
if (!activityHistoryReady) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await window.goodbuddy.activityHistory.replace(
|
||||
activityRecordsRef.current,
|
||||
legacyActivityHistoryMayBeIncompleteRef.current
|
||||
)
|
||||
} catch {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: tRef.current('notices.activityHistoryPersistenceFailed'),
|
||||
dedupeKey: 'activity-history-persistence'
|
||||
})
|
||||
}
|
||||
}, [activityHistoryReady])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
window.goodbuddy.app.onBeforeQuit(async () => {
|
||||
saveActivityRecords(activityRecordsRef.current)
|
||||
await persistActivityHistory()
|
||||
if (!conversationStoreReady) {
|
||||
return
|
||||
}
|
||||
@@ -5120,7 +5088,11 @@ function App(): React.JSX.Element {
|
||||
persistLocalConversationChanges()
|
||||
await conversationPersistenceQueueRef.current
|
||||
}),
|
||||
[conversationStoreReady, persistLocalConversationChanges]
|
||||
[
|
||||
conversationStoreReady,
|
||||
persistActivityHistory,
|
||||
persistLocalConversationChanges
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -5437,19 +5409,88 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void (async () => {
|
||||
let snapshot: ActivityHistorySnapshot
|
||||
try {
|
||||
snapshot = await window.goodbuddy.activityHistory.get()
|
||||
} catch {
|
||||
if (active) {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: tRef.current('notices.activityHistoryReadFailed'),
|
||||
dedupeKey: 'activity-history-read'
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
const legacyHistoryMayBeIncomplete =
|
||||
legacyActivityHistory.historyMayBeIncomplete ||
|
||||
snapshot.legacyHistoryMayBeIncomplete
|
||||
if (
|
||||
legacyActivityHistory.records.length > 0 ||
|
||||
legacyActivityHistory.historyMayBeIncomplete
|
||||
) {
|
||||
try {
|
||||
await window.goodbuddy.activityHistory.replace(
|
||||
mergeActivityRecords(
|
||||
legacyActivityHistory.records,
|
||||
snapshot.records
|
||||
),
|
||||
legacyHistoryMayBeIncomplete
|
||||
)
|
||||
clearLegacyActivityHistory()
|
||||
} catch {
|
||||
notify({
|
||||
tone: 'error',
|
||||
message: tRef.current(
|
||||
'notices.activityHistoryPersistenceFailed'
|
||||
),
|
||||
dedupeKey: 'activity-history-persistence'
|
||||
})
|
||||
}
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
}
|
||||
setActivityRecords((current) =>
|
||||
mergeActivityRecords(current, snapshot.records)
|
||||
)
|
||||
setLegacyActivityHistoryMayBeIncomplete(legacyHistoryMayBeIncomplete)
|
||||
setActivityHistoryReady(true)
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [legacyActivityHistory])
|
||||
|
||||
useEffect(() => {
|
||||
activityRecordsRef.current = activityRecords
|
||||
legacyActivityHistoryMayBeIncompleteRef.current =
|
||||
legacyActivityHistoryMayBeIncomplete
|
||||
if (!activityHistoryReady) {
|
||||
return
|
||||
}
|
||||
const timeout = window.setTimeout(() => {
|
||||
saveActivityRecords(activityRecords)
|
||||
void persistActivityHistory()
|
||||
}, 250)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [activityRecords])
|
||||
}, [
|
||||
activityHistoryReady,
|
||||
activityRecords,
|
||||
legacyActivityHistoryMayBeIncomplete,
|
||||
persistActivityHistory
|
||||
])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
saveActivityRecords(activityRecordsRef.current)
|
||||
void persistActivityHistory()
|
||||
},
|
||||
[]
|
||||
[persistActivityHistory]
|
||||
)
|
||||
|
||||
const resumeProjectConversationQueues = useCallback(
|
||||
@@ -5769,11 +5810,15 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
|
||||
const loadWorkspaceFile = useCallback(
|
||||
async (path: string) => {
|
||||
async (path: string, offsetBytes = 0) => {
|
||||
if (!activeProjectId) {
|
||||
throw new Error(tRef.current('notices.selectProject'))
|
||||
}
|
||||
return window.goodbuddy.workspace.readFile(activeProjectId, path)
|
||||
return window.goodbuddy.workspace.readFile(
|
||||
activeProjectId,
|
||||
path,
|
||||
offsetBytes
|
||||
)
|
||||
},
|
||||
[activeProjectId]
|
||||
)
|
||||
@@ -8173,6 +8218,8 @@ function App(): React.JSX.Element {
|
||||
persistedLocalConversationsRef.current.clear()
|
||||
setConversations([conversation])
|
||||
setActiveId(conversation.id)
|
||||
legacyActivityHistoryMayBeIncompleteRef.current = false
|
||||
setLegacyActivityHistoryMayBeIncomplete(false)
|
||||
setActivityRecords([])
|
||||
setAssistantTasks([])
|
||||
setTokenUsage(emptyTokenUsage)
|
||||
@@ -10871,7 +10918,14 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
>
|
||||
<ActivityPanel
|
||||
onClear={() => setActivityRecords([])}
|
||||
legacyHistoryMayBeIncomplete={
|
||||
legacyActivityHistoryMayBeIncomplete
|
||||
}
|
||||
onClear={() => {
|
||||
legacyActivityHistoryMayBeIncompleteRef.current = false
|
||||
setLegacyActivityHistoryMayBeIncomplete(false)
|
||||
setActivityRecords([])
|
||||
}}
|
||||
onOpenConversation={openActivityConversation}
|
||||
projects={projects}
|
||||
records={activityRecords}
|
||||
|
||||
@@ -55,7 +55,8 @@ function renderSidebar({
|
||||
}>
|
||||
onLoadArtifact?: (artifactId: string) => Promise<void>
|
||||
onLoadWorkspaceFile?: (
|
||||
path: string
|
||||
path: string,
|
||||
offsetBytes?: number
|
||||
) => Promise<WorkspaceFilePreview>
|
||||
workspaceProjectId?: string
|
||||
restoreFocusRef?: { current: HTMLElement | null }
|
||||
@@ -330,7 +331,10 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
name: 'README.md',
|
||||
content: 'Recovered preview',
|
||||
mimeType: 'text/plain',
|
||||
size: 17
|
||||
size: 17,
|
||||
offsetBytes: 0,
|
||||
nextOffsetBytes: 17,
|
||||
truncated: false
|
||||
})
|
||||
renderSidebar({
|
||||
tab: 'workspace',
|
||||
@@ -372,7 +376,70 @@ describe('RightAssistantSidebar resizing', () => {
|
||||
expect(await screen.findByText('Recovered preview'))
|
||||
.toBeInTheDocument()
|
||||
expect(onLoadWorkspaceFile).toHaveBeenCalledTimes(2)
|
||||
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(2, 'README.md')
|
||||
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'README.md',
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('loads and appends the rest of a large workspace file', async () => {
|
||||
const onLoadWorkspaceFile = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
path: 'large.txt',
|
||||
name: 'large.txt',
|
||||
content: 'first page\n',
|
||||
mimeType: 'text/plain',
|
||||
size: 30,
|
||||
offsetBytes: 0,
|
||||
nextOffsetBytes: 11,
|
||||
truncated: true
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
path: 'large.txt',
|
||||
name: 'large.txt',
|
||||
content: 'second page',
|
||||
mimeType: 'text/plain',
|
||||
size: 30,
|
||||
offsetBytes: 11,
|
||||
nextOffsetBytes: 30,
|
||||
truncated: false
|
||||
})
|
||||
renderSidebar({
|
||||
tab: 'workspace',
|
||||
workspaceProjectId: 'project-1',
|
||||
onListWorkspaceDirectory: vi.fn(async (path: string) => ({
|
||||
path,
|
||||
entries: [
|
||||
{
|
||||
name: 'large.txt',
|
||||
path: 'large.txt',
|
||||
type: 'file' as const
|
||||
}
|
||||
],
|
||||
truncated: false
|
||||
})),
|
||||
onLoadWorkspaceFile
|
||||
})
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: 'large.txt' })
|
||||
)
|
||||
expect(await screen.findByText('first page')).toBeInTheDocument()
|
||||
expect(screen.getByText('已加载 11 / 30 字节')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续加载' }))
|
||||
|
||||
expect(await screen.findByText(/first page\s+second page/u))
|
||||
.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '继续加载' }))
|
||||
.not.toBeInTheDocument()
|
||||
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'large.txt',
|
||||
11
|
||||
)
|
||||
})
|
||||
|
||||
it('previews a result without switching to a separate tab', () => {
|
||||
|
||||
@@ -120,7 +120,10 @@ type RightAssistantSidebarProps = {
|
||||
onListWorkspaceDirectory: (
|
||||
path: string
|
||||
) => Promise<WorkspaceDirectoryListing>
|
||||
onLoadWorkspaceFile: (path: string) => Promise<WorkspaceFilePreview>
|
||||
onLoadWorkspaceFile: (
|
||||
path: string,
|
||||
offsetBytes?: number
|
||||
) => Promise<WorkspaceFilePreview>
|
||||
onOpenWorkspaceEntry: (
|
||||
path: string,
|
||||
type: 'file' | 'directory'
|
||||
@@ -622,6 +625,10 @@ export function RightAssistantSidebar({
|
||||
}
|
||||
>()
|
||||
const workspacePreviewRequest = useRef(0)
|
||||
const [workspacePreviewLoadingMore, setWorkspacePreviewLoadingMore] =
|
||||
useState(false)
|
||||
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
|
||||
useState('')
|
||||
const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0)
|
||||
const [taskFilter, setTaskFilter] = useState<
|
||||
'attention' | 'active' | 'paused' | 'finished'
|
||||
@@ -1037,8 +1044,10 @@ export function RightAssistantSidebar({
|
||||
workspacePreviewRequest.current = requestId
|
||||
const projectId = workspaceProjectId
|
||||
setWorkspacePreview({ projectId, path, state: 'loading' })
|
||||
setWorkspacePreviewLoadingMore(false)
|
||||
setWorkspacePreviewLoadMoreError('')
|
||||
setActionError('')
|
||||
void onLoadWorkspaceFile(path)
|
||||
void onLoadWorkspaceFile(path, 0)
|
||||
.then((file) => {
|
||||
if (workspacePreviewRequest.current === requestId) {
|
||||
setWorkspacePreview({
|
||||
@@ -1065,6 +1074,59 @@ export function RightAssistantSidebar({
|
||||
})
|
||||
}
|
||||
|
||||
const loadMoreWorkspaceFile = (): void => {
|
||||
const preview = currentWorkspacePreview
|
||||
if (
|
||||
preview?.state !== 'ready' ||
|
||||
!preview.file.truncated ||
|
||||
workspacePreviewLoadingMore
|
||||
) {
|
||||
return
|
||||
}
|
||||
const requestId = workspacePreviewRequest.current
|
||||
const expectedOffset = preview.file.nextOffsetBytes
|
||||
setWorkspacePreviewLoadingMore(true)
|
||||
setWorkspacePreviewLoadMoreError('')
|
||||
void onLoadWorkspaceFile(preview.path, expectedOffset)
|
||||
.then((nextPage) => {
|
||||
if (workspacePreviewRequest.current !== requestId) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
nextPage.path !== preview.file.path ||
|
||||
nextPage.offsetBytes !== expectedOffset ||
|
||||
nextPage.size !== preview.file.size ||
|
||||
nextPage.mimeType !== preview.file.mimeType
|
||||
) {
|
||||
throw new Error(t('sidebar.errors.workspacePreview'))
|
||||
}
|
||||
setWorkspacePreview({
|
||||
projectId: preview.projectId,
|
||||
path: preview.path,
|
||||
state: 'ready',
|
||||
file: {
|
||||
...nextPage,
|
||||
content: `${preview.file.content}${nextPage.content}`,
|
||||
offsetBytes: preview.file.offsetBytes
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (workspacePreviewRequest.current === requestId) {
|
||||
setWorkspacePreviewLoadMoreError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t('sidebar.errors.workspacePreview')
|
||||
)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (workspacePreviewRequest.current === requestId) {
|
||||
setWorkspacePreviewLoadingMore(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const runAction = async (
|
||||
action: () => Promise<void>,
|
||||
fallback: string,
|
||||
@@ -1426,6 +1488,8 @@ export function RightAssistantSidebar({
|
||||
onClick={() => {
|
||||
workspacePreviewRequest.current += 1
|
||||
setWorkspacePreview(undefined)
|
||||
setWorkspacePreviewLoadingMore(false)
|
||||
setWorkspacePreviewLoadMoreError('')
|
||||
setActionError('')
|
||||
}}
|
||||
type="button"
|
||||
@@ -1471,16 +1535,50 @@ export function RightAssistantSidebar({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="markdown-body markdown-content">
|
||||
{currentWorkspacePreview.file.mimeType ===
|
||||
'text/markdown' ? (
|
||||
<MarkdownRenderer>
|
||||
{currentWorkspacePreview.file.content}
|
||||
</MarkdownRenderer>
|
||||
) : (
|
||||
<pre>{currentWorkspacePreview.file.content}</pre>
|
||||
<>
|
||||
<div className="markdown-body markdown-content">
|
||||
{currentWorkspacePreview.file.mimeType ===
|
||||
'text/markdown' ? (
|
||||
<MarkdownRenderer>
|
||||
{currentWorkspacePreview.file.content}
|
||||
</MarkdownRenderer>
|
||||
) : (
|
||||
<pre>{currentWorkspacePreview.file.content}</pre>
|
||||
)}
|
||||
</div>
|
||||
{currentWorkspacePreview.file.truncated && (
|
||||
<div className="assistant-sidebar__preview-more">
|
||||
<p>
|
||||
{t('sidebar.workspace.partialPreview', {
|
||||
loaded: currentWorkspacePreview.file
|
||||
.nextOffsetBytes.toLocaleString(locale),
|
||||
total:
|
||||
currentWorkspacePreview.file.size.toLocaleString(
|
||||
locale
|
||||
)
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={workspacePreviewLoadingMore}
|
||||
onClick={loadMoreWorkspaceFile}
|
||||
type="button"
|
||||
>
|
||||
{workspacePreviewLoadingMore
|
||||
? t('sidebar.workspace.loadingMore')
|
||||
: t('sidebar.workspace.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{workspacePreviewLoadMoreError && (
|
||||
<p
|
||||
className="assistant-sidebar__preview-more-error"
|
||||
role="alert"
|
||||
>
|
||||
{workspacePreviewLoadMoreError}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ACTIVITY_STORAGE_KEY,
|
||||
MAX_ACTIVITY_DETAIL_LENGTH,
|
||||
MAX_ACTIVITY_RECORDS,
|
||||
loadActivityRecords,
|
||||
clearLegacyActivityHistory,
|
||||
loadLegacyActivityHistory,
|
||||
mergeActivityRecords,
|
||||
reconcileActivityRecords,
|
||||
saveActivityRecords,
|
||||
upsertActivityRecord,
|
||||
type ActivityRecord
|
||||
} from './activity-store'
|
||||
@@ -32,14 +31,14 @@ describe('activity-store', () => {
|
||||
|
||||
it('returns an empty history for inaccessible or corrupt storage', () => {
|
||||
localStorage.setItem(ACTIVITY_STORAGE_KEY, '{invalid')
|
||||
expect(loadActivityRecords()).toEqual([])
|
||||
expect(loadLegacyActivityHistory().records).toEqual([])
|
||||
|
||||
const inaccessibleStorage = {
|
||||
getItem: () => {
|
||||
throw new Error('blocked')
|
||||
}
|
||||
} as unknown as Storage
|
||||
expect(loadActivityRecords(inaccessibleStorage)).toEqual([])
|
||||
expect(loadLegacyActivityHistory(inaccessibleStorage).records).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps only schema-valid records from untrusted storage', () => {
|
||||
@@ -49,12 +48,11 @@ describe('activity-store', () => {
|
||||
JSON.stringify([
|
||||
validRecord,
|
||||
{ ...validRecord, status: 'unknown' },
|
||||
{ ...validRecord, detail: 'x'.repeat(MAX_ACTIVITY_DETAIL_LENGTH + 1) },
|
||||
null
|
||||
])
|
||||
)
|
||||
|
||||
expect(loadActivityRecords()).toEqual([validRecord])
|
||||
expect(loadLegacyActivityHistory().records).toEqual([validRecord])
|
||||
})
|
||||
|
||||
it('loads legacy records with an explicit unavailable scope', () => {
|
||||
@@ -65,7 +63,7 @@ describe('activity-store', () => {
|
||||
JSON.stringify([legacyRecord])
|
||||
)
|
||||
|
||||
expect(loadActivityRecords()).toEqual([
|
||||
expect(loadLegacyActivityHistory().records).toEqual([
|
||||
expect.objectContaining({
|
||||
id: legacyRecord.id,
|
||||
scope: { kind: 'unavailable' }
|
||||
@@ -89,50 +87,51 @@ describe('activity-store', () => {
|
||||
])
|
||||
)
|
||||
|
||||
expect(loadActivityRecords()).toEqual([])
|
||||
expect(loadLegacyActivityHistory().records).toEqual([])
|
||||
})
|
||||
|
||||
it('persists no more than the record limit', () => {
|
||||
it('loads more than the former record limit without shortening details', () => {
|
||||
const records = Array.from(
|
||||
{ length: MAX_ACTIVITY_RECORDS + 1 },
|
||||
(_, index) => makeRecord(index)
|
||||
)
|
||||
|
||||
expect(saveActivityRecords(records)).toBe(true)
|
||||
expect(loadActivityRecords()).toHaveLength(MAX_ACTIVITY_RECORDS)
|
||||
expect(loadActivityRecords().at(-1)?.id).toBe(
|
||||
`activity-${MAX_ACTIVITY_RECORDS - 1}`
|
||||
)
|
||||
})
|
||||
|
||||
it('drops the oldest records until the stored JSON fits the load bound', () => {
|
||||
const records = Array.from(
|
||||
{ length: MAX_ACTIVITY_RECORDS },
|
||||
{ length: 501 },
|
||||
(_, index) => ({
|
||||
...makeRecord(index),
|
||||
detail: 'x'.repeat(4_000)
|
||||
detail: index === 500 ? 'x'.repeat(4_001) : '读取文件'
|
||||
})
|
||||
)
|
||||
localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records))
|
||||
|
||||
expect(saveActivityRecords(records)).toBe(true)
|
||||
const serialized = localStorage.getItem(ACTIVITY_STORAGE_KEY)
|
||||
expect(serialized?.length).toBeLessThanOrEqual(2_000_000)
|
||||
const restored = loadActivityRecords()
|
||||
expect(restored.length).toBeGreaterThan(0)
|
||||
expect(restored.length).toBeLessThan(MAX_ACTIVITY_RECORDS)
|
||||
expect(restored[0]?.id).toBe('activity-0')
|
||||
const legacy = loadLegacyActivityHistory()
|
||||
expect(legacy.records).toHaveLength(501)
|
||||
expect(legacy.records.at(-1)?.detail).toHaveLength(4_001)
|
||||
expect(legacy.historyMayBeIncomplete).toBe(true)
|
||||
})
|
||||
|
||||
it('reports rejected writes without throwing', () => {
|
||||
const rejectingStorage = {
|
||||
setItem: () => {
|
||||
throw new Error('quota exceeded')
|
||||
}
|
||||
} as unknown as Storage
|
||||
|
||||
expect(saveActivityRecords([makeRecord(1)], rejectingStorage)).toBe(
|
||||
false
|
||||
it('marks a legacy maximum-length detail as possibly incomplete', () => {
|
||||
localStorage.setItem(
|
||||
ACTIVITY_STORAGE_KEY,
|
||||
JSON.stringify([{ ...makeRecord(1), detail: 'x'.repeat(4_000) }])
|
||||
)
|
||||
|
||||
expect(loadLegacyActivityHistory()).toMatchObject({
|
||||
historyMayBeIncomplete: true
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the legacy migration source without throwing', () => {
|
||||
localStorage.setItem(
|
||||
ACTIVITY_STORAGE_KEY,
|
||||
JSON.stringify([makeRecord(1)])
|
||||
)
|
||||
clearLegacyActivityHistory()
|
||||
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull()
|
||||
|
||||
expect(() =>
|
||||
clearLegacyActivityHistory({
|
||||
removeItem: () => {
|
||||
throw new Error('blocked')
|
||||
}
|
||||
} as unknown as Storage)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('upserts transitions for one call while preserving distinct calls', () => {
|
||||
@@ -183,7 +182,7 @@ describe('activity-store', () => {
|
||||
).toEqual(first.scope)
|
||||
})
|
||||
|
||||
it('persists and upserts Subagent state transitions', () => {
|
||||
it('upserts Subagent state transitions', () => {
|
||||
const queued: ActivityRecord = {
|
||||
...makeRecord(1),
|
||||
kind: 'subagent',
|
||||
@@ -206,8 +205,25 @@ describe('activity-store', () => {
|
||||
status: 'completed'
|
||||
})
|
||||
])
|
||||
expect(saveActivityRecords(completed)).toBe(true)
|
||||
expect(loadActivityRecords()).toEqual(completed)
|
||||
})
|
||||
|
||||
it('keeps more than 500 distinct activity records', () => {
|
||||
const records = Array.from({ length: 501 }, (_, index) =>
|
||||
makeRecord(index)
|
||||
)
|
||||
|
||||
expect(
|
||||
upsertActivityRecord(records, makeRecord(501))
|
||||
).toHaveLength(502)
|
||||
})
|
||||
|
||||
it('merges migrated and persisted records without duplicating IDs', () => {
|
||||
expect(
|
||||
mergeActivityRecords(
|
||||
[makeRecord(2), makeRecord(1)],
|
||||
[makeRecord(1), makeRecord(0)]
|
||||
).map((record) => record.id)
|
||||
).toEqual(['activity-2', 'activity-1', 'activity-0'])
|
||||
})
|
||||
|
||||
it('reconciles stale active records with durable task outcomes', () => {
|
||||
|
||||
@@ -1,47 +1,24 @@
|
||||
import i18n from './i18n'
|
||||
|
||||
import type { AssistantTask } from '../../shared/assistant-contracts'
|
||||
import {
|
||||
activityRecordSchema,
|
||||
type ActivityRecord as SharedActivityRecord,
|
||||
type AssistantTask
|
||||
} from '../../shared/assistant-contracts'
|
||||
|
||||
export const ACTIVITY_STORAGE_KEY = 'goodbuddy.activity-records.v1'
|
||||
export const MAX_ACTIVITY_RECORDS = 500
|
||||
export const MAX_ACTIVITY_DETAIL_LENGTH = 4_000
|
||||
|
||||
const MAX_STORED_JSON_LENGTH = 2_000_000
|
||||
const MAX_ID_LENGTH = 256
|
||||
const MAX_TITLE_LENGTH = 240
|
||||
const MAX_PROJECT_NAME_LENGTH = 120
|
||||
const LEGACY_MAX_ACTIVITY_RECORDS = 500
|
||||
const LEGACY_MAX_ACTIVITY_DETAIL_LENGTH = 4_000
|
||||
const LEGACY_MAX_STORED_JSON_LENGTH = 2_000_000
|
||||
const LEGACY_NEAR_STORAGE_LIMIT_LENGTH =
|
||||
LEGACY_MAX_STORED_JSON_LENGTH - 10_000
|
||||
|
||||
const activityKinds = [
|
||||
'request',
|
||||
'tool',
|
||||
'approval',
|
||||
'subagent',
|
||||
'result'
|
||||
] as const
|
||||
const activityStatuses = [
|
||||
'pending',
|
||||
'running',
|
||||
'completed',
|
||||
'failed',
|
||||
'denied',
|
||||
'cancelled',
|
||||
'interrupted'
|
||||
] as const
|
||||
export type ActivityRecord = SharedActivityRecord
|
||||
|
||||
export type ActivityRecord = {
|
||||
id: string
|
||||
conversationId: string
|
||||
requestId: string
|
||||
callId?: string
|
||||
scope:
|
||||
| { kind: 'global' }
|
||||
| { kind: 'project'; projectId: string; projectName: string }
|
||||
| { kind: 'unavailable' }
|
||||
kind: (typeof activityKinds)[number]
|
||||
title: string
|
||||
detail: string
|
||||
status: (typeof activityStatuses)[number]
|
||||
createdAt: number
|
||||
export type LegacyActivityHistory = {
|
||||
records: ActivityRecord[]
|
||||
historyMayBeIncomplete: boolean
|
||||
}
|
||||
|
||||
function getLocalStorage(): Storage | undefined {
|
||||
@@ -52,94 +29,16 @@ function getLocalStorage(): Storage | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function isBoundedString(
|
||||
value: unknown,
|
||||
maximumLength: number,
|
||||
allowEmpty = false
|
||||
): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length <= maximumLength &&
|
||||
(allowEmpty || value.length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function parseActivityScope(
|
||||
value: unknown
|
||||
): ActivityRecord['scope'] | undefined {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
const candidate = value as Record<string, unknown>
|
||||
if (candidate.kind === 'global') {
|
||||
return { kind: 'global' }
|
||||
}
|
||||
if (candidate.kind === 'unavailable') {
|
||||
return { kind: 'unavailable' }
|
||||
}
|
||||
if (
|
||||
candidate.kind === 'project' &&
|
||||
isBoundedString(candidate.projectId, MAX_ID_LENGTH) &&
|
||||
isBoundedString(candidate.projectName, MAX_PROJECT_NAME_LENGTH)
|
||||
) {
|
||||
return {
|
||||
kind: 'project',
|
||||
projectId: candidate.projectId,
|
||||
projectName: candidate.projectName
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseActivityRecord(value: unknown): ActivityRecord | undefined {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>
|
||||
if (
|
||||
!isBoundedString(candidate.id, MAX_ID_LENGTH) ||
|
||||
!isBoundedString(candidate.conversationId, MAX_ID_LENGTH) ||
|
||||
!isBoundedString(candidate.requestId, MAX_ID_LENGTH) ||
|
||||
(candidate.callId !== undefined &&
|
||||
!isBoundedString(candidate.callId, MAX_ID_LENGTH)) ||
|
||||
!activityKinds.some((kind) => kind === candidate.kind) ||
|
||||
!isBoundedString(candidate.title, MAX_TITLE_LENGTH) ||
|
||||
!isBoundedString(
|
||||
candidate.detail,
|
||||
MAX_ACTIVITY_DETAIL_LENGTH,
|
||||
true
|
||||
) ||
|
||||
!activityStatuses.some((status) => status === candidate.status) ||
|
||||
typeof candidate.createdAt !== 'number' ||
|
||||
!Number.isFinite(candidate.createdAt) ||
|
||||
candidate.createdAt < 0
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const scope =
|
||||
candidate.scope === undefined
|
||||
? { kind: 'unavailable' as const }
|
||||
: parseActivityScope(candidate.scope)
|
||||
if (!scope) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
id: candidate.id,
|
||||
conversationId: candidate.conversationId,
|
||||
requestId: candidate.requestId,
|
||||
...(candidate.callId === undefined
|
||||
? {}
|
||||
: { callId: candidate.callId }),
|
||||
scope,
|
||||
kind: candidate.kind as ActivityRecord['kind'],
|
||||
title: candidate.title,
|
||||
detail: candidate.detail,
|
||||
status: candidate.status as ActivityRecord['status'],
|
||||
createdAt: candidate.createdAt
|
||||
}
|
||||
const parsed = activityRecordSchema.safeParse({
|
||||
...candidate,
|
||||
scope: candidate.scope ?? { kind: 'unavailable' }
|
||||
})
|
||||
return parsed.success ? parsed.data : undefined
|
||||
}
|
||||
|
||||
export function upsertActivityRecord(
|
||||
@@ -150,7 +49,7 @@ export function upsertActivityRecord(
|
||||
(incoming.kind !== 'tool' && incoming.kind !== 'subagent') ||
|
||||
!incoming.callId
|
||||
) {
|
||||
return [incoming, ...records].slice(0, MAX_ACTIVITY_RECORDS)
|
||||
return [incoming, ...records]
|
||||
}
|
||||
|
||||
const existingIndex = records.findIndex(
|
||||
@@ -160,7 +59,7 @@ export function upsertActivityRecord(
|
||||
record.callId === incoming.callId
|
||||
)
|
||||
if (existingIndex < 0) {
|
||||
return [incoming, ...records].slice(0, MAX_ACTIVITY_RECORDS)
|
||||
return [incoming, ...records]
|
||||
}
|
||||
|
||||
const existing = records[existingIndex]!
|
||||
@@ -172,7 +71,21 @@ export function upsertActivityRecord(
|
||||
scope: existing.scope
|
||||
},
|
||||
...records.filter((_, index) => index !== existingIndex)
|
||||
].slice(0, MAX_ACTIVITY_RECORDS)
|
||||
]
|
||||
}
|
||||
|
||||
export function mergeActivityRecords(
|
||||
primary: readonly ActivityRecord[],
|
||||
secondary: readonly ActivityRecord[]
|
||||
): ActivityRecord[] {
|
||||
const seen = new Set<string>()
|
||||
return [...primary, ...secondary].filter((record) => {
|
||||
if (seen.has(record.id)) {
|
||||
return false
|
||||
}
|
||||
seen.add(record.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function taskTerminalStatus(
|
||||
@@ -227,38 +140,33 @@ export function reconcileActivityRecords(
|
||||
? `${record.detail}\n${i18n.t(
|
||||
'records.interruptedOnRestart',
|
||||
{ ns: 'activity' }
|
||||
)}`.slice(
|
||||
0,
|
||||
MAX_ACTIVITY_DETAIL_LENGTH
|
||||
)
|
||||
)}`
|
||||
: record.detail
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads only records matching the persisted activity schema. Corrupt storage,
|
||||
* inaccessible storage and oversized payloads are treated as an empty history.
|
||||
* Reads the previous Renderer-owned history once for migration to SQLite.
|
||||
* The legacy writer silently stopped at fixed record and payload limits, so
|
||||
* reaching one of those boundaries is retained as durable uncertainty.
|
||||
*/
|
||||
export function loadActivityRecords(
|
||||
export function loadLegacyActivityHistory(
|
||||
storage: Storage | undefined = getLocalStorage()
|
||||
): ActivityRecord[] {
|
||||
): LegacyActivityHistory {
|
||||
if (!storage) {
|
||||
return []
|
||||
return { records: [], historyMayBeIncomplete: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const serialized = storage.getItem(ACTIVITY_STORAGE_KEY)
|
||||
if (
|
||||
serialized === null ||
|
||||
serialized.length > MAX_STORED_JSON_LENGTH
|
||||
) {
|
||||
return []
|
||||
if (serialized === null) {
|
||||
return { records: [], historyMayBeIncomplete: false }
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(serialized)
|
||||
if (!Array.isArray(parsed)) {
|
||||
return []
|
||||
return { records: [], historyMayBeIncomplete: false }
|
||||
}
|
||||
|
||||
const records: ActivityRecord[] = []
|
||||
@@ -267,57 +175,31 @@ export function loadActivityRecords(
|
||||
if (record) {
|
||||
records.push(record)
|
||||
}
|
||||
if (records.length === MAX_ACTIVITY_RECORDS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return records
|
||||
return {
|
||||
records,
|
||||
historyMayBeIncomplete:
|
||||
records.length >= LEGACY_MAX_ACTIVITY_RECORDS ||
|
||||
serialized.length >= LEGACY_NEAR_STORAGE_LIMIT_LENGTH ||
|
||||
records.some(
|
||||
(record) =>
|
||||
record.detail.length === LEGACY_MAX_ACTIVITY_DETAIL_LENGTH
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return []
|
||||
return { records: [], historyMayBeIncomplete: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists as many of the newest schema-valid records as fit within the
|
||||
* bounded storage payload. Returns false if storage is unavailable or rejects
|
||||
* the write.
|
||||
*/
|
||||
export function saveActivityRecords(
|
||||
records: readonly ActivityRecord[],
|
||||
export function clearLegacyActivityHistory(
|
||||
storage: Storage | undefined = getLocalStorage()
|
||||
): boolean {
|
||||
): void {
|
||||
if (!storage) {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
const serializedRecords: string[] = []
|
||||
let serializedLength = 2
|
||||
for (const record of records) {
|
||||
const safeRecord = parseActivityRecord(record)
|
||||
if (safeRecord) {
|
||||
const serializedRecord = JSON.stringify(safeRecord)
|
||||
const nextLength =
|
||||
serializedLength +
|
||||
serializedRecord.length +
|
||||
(serializedRecords.length > 0 ? 1 : 0)
|
||||
if (nextLength > MAX_STORED_JSON_LENGTH) {
|
||||
break
|
||||
}
|
||||
serializedRecords.push(serializedRecord)
|
||||
serializedLength = nextLength
|
||||
}
|
||||
if (serializedRecords.length === MAX_ACTIVITY_RECORDS) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(
|
||||
ACTIVITY_STORAGE_KEY,
|
||||
`[${serializedRecords.join(',')}]`
|
||||
)
|
||||
return true
|
||||
storage.removeItem(ACTIVITY_STORAGE_KEY)
|
||||
} catch {
|
||||
return false
|
||||
// A failed cleanup leaves the migration source intact for the next start.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,12 +115,15 @@ export const activity = {
|
||||
unknownTime: 'Unknown time',
|
||||
interruptedOnRestart:
|
||||
'This activity had not finished when the app restarted.',
|
||||
legacyHistoryMayBeIncomplete:
|
||||
'Older versions limited local run history and detail length, so earlier records or parts of their details may already be missing. New records are saved in full.',
|
||||
unavailableScope:
|
||||
'The scope could not be determined when this activity was recorded.',
|
||||
conversation: 'Conversation: {{title}}',
|
||||
activityCount: 'Activity items: {{formattedCount}}',
|
||||
projectSummary:
|
||||
'{{conversationCount}} tasks or conversations · {{activityCount}} activity items',
|
||||
loadMore: 'Show more ({{remaining}} remaining)',
|
||||
openConversation: 'Open conversation'
|
||||
}
|
||||
} satisfies TranslationShape<typeof chineseActivity>
|
||||
|
||||
@@ -643,6 +643,9 @@ export const app = {
|
||||
'Failed to resume this conversation’s send queue',
|
||||
conversationPersistenceFailed:
|
||||
'Failed to save conversations. Check local storage.',
|
||||
activityHistoryPersistenceFailed:
|
||||
'Failed to save run history. Check local storage.',
|
||||
activityHistoryReadFailed: 'Failed to read run history',
|
||||
remoteConversationRefreshFailed:
|
||||
'Failed to refresh remote channel conversations',
|
||||
projectReadFailed: 'Failed to read projects',
|
||||
|
||||
@@ -327,6 +327,9 @@ export const workspace = {
|
||||
fileSize: '{{formattedSize}} bytes',
|
||||
fileFallback: 'Project workspace file',
|
||||
reading: 'Reading file…',
|
||||
partialPreview: 'Loaded {{loaded}} / {{total}} bytes',
|
||||
loadMore: 'Load more',
|
||||
loadingMore: 'Loading…',
|
||||
refreshAriaLabel: 'Refresh workspace files',
|
||||
refresh: 'Refresh',
|
||||
gitUnavailable: 'Git status unavailable: {{error}}',
|
||||
|
||||
@@ -111,12 +111,15 @@ export const activity = {
|
||||
records: {
|
||||
unknownTime: '时间未知',
|
||||
interruptedOnRestart: '应用重启时此活动尚未结束。',
|
||||
legacyHistoryMayBeIncomplete:
|
||||
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。',
|
||||
unavailableScope:
|
||||
'创建此活动记录时未能确定其归属范围。',
|
||||
conversation: '对话:{{title}}',
|
||||
activityCount: '{{formattedCount}} 条活动',
|
||||
projectSummary:
|
||||
'{{conversationCount}} 个任务或会话 · {{activityCount}} 条活动',
|
||||
loadMore: '继续显示(剩余 {{remaining}} 条)',
|
||||
openConversation: '打开所属对话'
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -595,6 +595,8 @@ export const app = {
|
||||
conversationQueueResumeFailed:
|
||||
'无法继续执行当前对话的待发送队列',
|
||||
conversationPersistenceFailed: '会话持久化失败,请检查本地存储',
|
||||
activityHistoryPersistenceFailed: '运行记录保存失败,请检查本地存储',
|
||||
activityHistoryReadFailed: '运行记录读取失败',
|
||||
remoteConversationRefreshFailed: '远程通道会话刷新失败',
|
||||
projectReadFailed: '项目读取失败',
|
||||
expertsReadFailed: '专家角色读取失败',
|
||||
|
||||
@@ -318,6 +318,9 @@ export const workspace = {
|
||||
fileSize: '{{formattedSize}} 字节',
|
||||
fileFallback: '项目工作区文件',
|
||||
reading: '正在读取文件…',
|
||||
partialPreview: '已加载 {{loaded}} / {{total}} 字节',
|
||||
loadMore: '继续加载',
|
||||
loadingMore: '正在加载…',
|
||||
refreshAriaLabel: '刷新工作区文件',
|
||||
refresh: '刷新',
|
||||
gitUnavailable: 'Git 状态不可用:{{error}}',
|
||||
|
||||
@@ -4959,6 +4959,25 @@ button > svg {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__preview-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3) 0;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.assistant-sidebar__preview-more p,
|
||||
.assistant-sidebar__preview-more-error {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.assistant-sidebar__preview-more-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.assistant-sidebar__import {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -13729,6 +13748,20 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.activity-panel__legacy-warning {
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid color-mix(in srgb, var(--warning) 45%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--warning) 10%, transparent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.activity-panel__load-more {
|
||||
align-self: center;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.activity-panel__tab-panel {
|
||||
min-width: 0;
|
||||
padding-bottom: var(--space-6);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AssistantProject } from './assistant-contracts'
|
||||
import {
|
||||
builtInDefaultProjectSeedDescription,
|
||||
builtInDefaultProjectSeedName,
|
||||
activityHistorySnapshotSchema,
|
||||
conversationBranchInputSchema,
|
||||
conversationMessageSchema,
|
||||
conversationSubagentActivitySchema,
|
||||
@@ -33,6 +34,29 @@ const untouchedProject: AssistantProject = {
|
||||
updatedAt: '2026-08-01T00:00:00.000Z'
|
||||
}
|
||||
|
||||
describe('activity history contracts', () => {
|
||||
it('accepts more than 500 records and details longer than 4,000 characters', () => {
|
||||
const records = Array.from({ length: 501 }, (_, index) => ({
|
||||
id: `activity-${index}`,
|
||||
conversationId: 'conversation-1',
|
||||
requestId: 'request-1',
|
||||
scope: { kind: 'global' as const },
|
||||
kind: 'tool' as const,
|
||||
title: `Activity ${index}`,
|
||||
detail: index === 500 ? 'x'.repeat(4_001) : '',
|
||||
status: 'completed' as const,
|
||||
createdAt: index
|
||||
}))
|
||||
|
||||
expect(
|
||||
activityHistorySnapshotSchema.parse({
|
||||
records,
|
||||
legacyHistoryMayBeIncomplete: true
|
||||
}).records
|
||||
).toHaveLength(501)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUntouchedBuiltInDefaultProject', () => {
|
||||
it('requires the persistent marker and exact seed text', () => {
|
||||
expect(isUntouchedBuiltInDefaultProject(untouchedProject)).toBe(true)
|
||||
@@ -174,14 +198,14 @@ describe('project execution space contracts', () => {
|
||||
})
|
||||
|
||||
describe('conversation activity contracts', () => {
|
||||
it('accepts long activity runs within the retained display bounds', () => {
|
||||
const tools = Array.from({ length: 101 }, (_, index) => ({
|
||||
it('accepts complete long activity runs without a fixed item limit', () => {
|
||||
const tools = Array.from({ length: 501 }, (_, index) => ({
|
||||
callId: `call-${index + 1}`,
|
||||
name: 'read',
|
||||
state: 'completed' as const,
|
||||
summary: `Tool ${index + 1}`
|
||||
}))
|
||||
const subagents = Array.from({ length: 101 }, (_, index) => ({
|
||||
const subagents = Array.from({ length: 501 }, (_, index) => ({
|
||||
childTaskId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
|
||||
routingMode: 'native' as const,
|
||||
actor: {
|
||||
@@ -203,23 +227,22 @@ describe('conversation activity contracts', () => {
|
||||
})),
|
||||
createdAt: 1,
|
||||
state: 'complete',
|
||||
displayCaptureTruncated: true,
|
||||
tools,
|
||||
subagents
|
||||
})
|
||||
).toMatchObject({
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({ callId: 'call-101' })
|
||||
expect.objectContaining({ callId: 'call-501' })
|
||||
]),
|
||||
subagents: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
childTaskId: '00000000-0000-4000-8000-000000000101'
|
||||
childTaskId: '00000000-0000-4000-8000-000000000501'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects conversation activity and block arrays beyond display bounds', () => {
|
||||
it('accepts message block arrays beyond the former display bound', () => {
|
||||
const baseMessage = {
|
||||
id: '00000000-0000-4000-8000-000000000999',
|
||||
role: 'assistant' as const,
|
||||
@@ -227,17 +250,6 @@ describe('conversation activity contracts', () => {
|
||||
createdAt: 1,
|
||||
state: 'complete' as const
|
||||
}
|
||||
expect(
|
||||
conversationMessageSchema.safeParse({
|
||||
...baseMessage,
|
||||
tools: Array.from({ length: 501 }, (_, index) => ({
|
||||
callId: `call-${index + 1}`,
|
||||
name: 'read',
|
||||
state: 'completed' as const,
|
||||
summary: `Tool ${index + 1}`
|
||||
}))
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
conversationMessageSchema.safeParse({
|
||||
...baseMessage,
|
||||
@@ -247,7 +259,7 @@ describe('conversation activity contracts', () => {
|
||||
content: 'x'
|
||||
}))
|
||||
}).success
|
||||
).toBe(false)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -31,6 +31,57 @@ export type ProjectChannel = z.infer<typeof projectChannelSchema>
|
||||
|
||||
const projectExecutionSpacePathSchema = z.string().max(4_096)
|
||||
|
||||
export const activityRecordSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(256),
|
||||
conversationId: z.string().min(1).max(256),
|
||||
requestId: z.string().min(1).max(256),
|
||||
callId: z.string().min(1).max(256).optional(),
|
||||
scope: z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('global') }).strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('project'),
|
||||
projectId: z.string().min(1).max(256),
|
||||
projectName: z.string().min(1).max(120)
|
||||
})
|
||||
.strict(),
|
||||
z.object({ kind: z.literal('unavailable') }).strict()
|
||||
]),
|
||||
kind: z.enum([
|
||||
'request',
|
||||
'tool',
|
||||
'approval',
|
||||
'subagent',
|
||||
'result'
|
||||
]),
|
||||
title: z.string().min(1).max(240),
|
||||
detail: z.string(),
|
||||
status: z.enum([
|
||||
'pending',
|
||||
'running',
|
||||
'completed',
|
||||
'failed',
|
||||
'denied',
|
||||
'cancelled',
|
||||
'interrupted'
|
||||
]),
|
||||
createdAt: z.number().finite().nonnegative()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const activityHistorySnapshotSchema = z
|
||||
.object({
|
||||
records: z.array(activityRecordSchema),
|
||||
legacyHistoryMayBeIncomplete: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ActivityRecord = z.infer<typeof activityRecordSchema>
|
||||
export type ActivityHistorySnapshot = z.infer<
|
||||
typeof activityHistorySnapshotSchema
|
||||
>
|
||||
|
||||
export const projectExecutionSpaceSchema = z.discriminatedUnion(
|
||||
'kind',
|
||||
[
|
||||
@@ -182,12 +233,9 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
|
||||
.strict()
|
||||
])
|
||||
|
||||
export const maximumConversationRetainedActivities = 500
|
||||
export const maximumConversationMessageBlocks = 2_000
|
||||
|
||||
export const conversationMessageBlocksSchema = z
|
||||
.array(conversationMessageBlockSchema)
|
||||
.max(maximumConversationMessageBlocks)
|
||||
export const conversationMessageBlocksSchema = z.array(
|
||||
conversationMessageBlockSchema
|
||||
)
|
||||
|
||||
export type ConversationToolActivity = z.infer<
|
||||
typeof conversationToolActivitySchema
|
||||
@@ -278,14 +326,8 @@ export const conversationMessageSchema = z
|
||||
.array(conversationContextCompressionMarkerSchema)
|
||||
.max(2)
|
||||
.optional(),
|
||||
tools: z
|
||||
.array(conversationToolActivitySchema)
|
||||
.max(maximumConversationRetainedActivities)
|
||||
.optional(),
|
||||
subagents: z
|
||||
.array(conversationSubagentActivitySchema)
|
||||
.max(maximumConversationRetainedActivities)
|
||||
.optional(),
|
||||
tools: z.array(conversationToolActivitySchema).optional(),
|
||||
subagents: z.array(conversationSubagentActivitySchema).optional(),
|
||||
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
||||
sourceReferences: z
|
||||
.array(
|
||||
@@ -559,6 +601,9 @@ export type WorkspaceFilePreview = {
|
||||
content: string
|
||||
mimeType: 'text/markdown' | 'text/plain' | 'application/json'
|
||||
size: number
|
||||
offsetBytes: number
|
||||
nextOffsetBytes: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type AssistantTaskStatus =
|
||||
|
||||
+13
-2
@@ -41,6 +41,8 @@ import {
|
||||
type AssistantHeartbeatRun,
|
||||
type AssistantExpert,
|
||||
type AssistantTask,
|
||||
type ActivityHistorySnapshot,
|
||||
type ActivityRecord,
|
||||
type TokenUsageSummary,
|
||||
type ConversationSnapshot,
|
||||
type ConversationAttachment,
|
||||
@@ -288,7 +290,8 @@ export const workspaceDirectoryRequestSchema = z
|
||||
export const workspaceFileRequestSchema = z
|
||||
.object({
|
||||
projectId: assistantIdSchema,
|
||||
path: workspaceRelativePathSchema
|
||||
path: workspaceRelativePathSchema,
|
||||
offsetBytes: z.number().int().nonnegative().safe().optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -1888,7 +1891,8 @@ export type DesktopApi = {
|
||||
) => Promise<WorkspaceDirectoryListing>
|
||||
readFile: (
|
||||
projectId: string,
|
||||
path: string
|
||||
path: string,
|
||||
offsetBytes?: number
|
||||
) => Promise<WorkspaceFilePreview>
|
||||
openPath: (
|
||||
projectId: string,
|
||||
@@ -1903,6 +1907,13 @@ export type DesktopApi = {
|
||||
status: Extract<AssistantTask['status'], 'completed' | 'cancelled'>
|
||||
) => Promise<void>
|
||||
}
|
||||
activityHistory: {
|
||||
get: () => Promise<ActivityHistorySnapshot>
|
||||
replace: (
|
||||
records: ActivityRecord[],
|
||||
legacyHistoryMayBeIncomplete: boolean
|
||||
) => Promise<void>
|
||||
}
|
||||
usage: {
|
||||
getTokenSummary: () => Promise<TokenUsageSummary>
|
||||
}
|
||||
|
||||
@@ -185,6 +185,8 @@ export const ipcChannels = {
|
||||
workspacePathOpen: 'workspace:path:open',
|
||||
tasksList: 'tasks:list',
|
||||
tasksSetStatus: 'tasks:set-status',
|
||||
activityHistoryGet: 'activity-history:get',
|
||||
activityHistoryReplace: 'activity-history:replace',
|
||||
tokenUsageSummary: 'usage:token-summary',
|
||||
artifactsList: 'artifacts:list',
|
||||
artifactsGet: 'artifacts:get',
|
||||
|
||||
Reference in New Issue
Block a user