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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user