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:
mesalogo
2026-09-05 00:31:42 +08:00
parent d180f63abd
commit 9588c4b901
35 changed files with 1316 additions and 546 deletions
+27 -6
View File
@@ -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', () => {
+49 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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', () => {
+109 -11
View File
@@ -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>
) : (
+61 -45
View File
@@ -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', () => {
+62 -180
View File
@@ -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}}',
+33
View File
@@ -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);