release: prepare Desktop 0.13.10
This commit is contained in:
@@ -3,6 +3,7 @@ import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text
|
||||
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
|
||||
import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { ExecutionStatsReader } from './execution-stats-reader'
|
||||
import { statSync } from 'node:fs'
|
||||
import { MagicNoteStorage } from '../magic-notes/magic-note-storage'
|
||||
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
||||
@@ -1786,13 +1787,20 @@ function deleteProjectRecords(
|
||||
}
|
||||
|
||||
export class AssistantDatabase {
|
||||
private static readonly executionStatsCacheTtlMs = 30_000
|
||||
private static readonly executionStatsCacheLimit = 8
|
||||
private database?: DatabaseSync
|
||||
private executionStatsReader?: ExecutionStatsReader
|
||||
private readonly noteStorage: MagicNoteStorage
|
||||
private readonly dirtyMagicNotes = new Set<string>()
|
||||
private readonly pendingMagicNoteCleanup = new Set<string | undefined>()
|
||||
private subagentProgress?: SubagentProgressStorage
|
||||
private channelEventWrites = 0
|
||||
private channelOutboxWrites = 0
|
||||
private readonly executionStatsCache = new Map<
|
||||
string,
|
||||
{ value: ExecutionStats; cachedAt: number; dataVersion: number; totalChanges: number }
|
||||
>()
|
||||
|
||||
constructor(
|
||||
private readonly databasePath: string,
|
||||
@@ -2196,6 +2204,9 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.executionStatsReader?.close()
|
||||
this.executionStatsReader = undefined
|
||||
this.executionStatsCache.clear()
|
||||
this.database?.close()
|
||||
this.database = undefined
|
||||
this.subagentProgress = undefined
|
||||
@@ -4520,11 +4531,82 @@ export class AssistantDatabase {
|
||||
activeRequestIds: ReadonlySet<string> = new Set()
|
||||
): ExecutionStats {
|
||||
const scope = executionStatsInputSchema.parse(input)
|
||||
const cacheKey = 'conversationId' in scope
|
||||
? `conversation:${scope.conversationId}`
|
||||
: `project:${scope.projectId}`
|
||||
const database = this.requireDatabase()
|
||||
// Never retain values read from a transaction that its caller can roll back.
|
||||
if (database.isTransaction || activeRequestIds.size > 0) {
|
||||
this.executionStatsCache.clear()
|
||||
return this.readExecutionStatsSnapshot(scope, activeRequestIds)
|
||||
}
|
||||
const dataVersion = Number((database.prepare('PRAGMA data_version').get() as { data_version: number }).data_version)
|
||||
const totalChanges = Number((database.prepare('SELECT total_changes() AS total_changes').get() as { total_changes: number }).total_changes)
|
||||
const cached = activeRequestIds.size === 0
|
||||
? this.executionStatsCache.get(cacheKey)
|
||||
: undefined
|
||||
if (cached && cached.dataVersion === dataVersion && cached.totalChanges === totalChanges &&
|
||||
Date.now() - cached.cachedAt < AssistantDatabase.executionStatsCacheTtlMs) {
|
||||
this.executionStatsCache.delete(cacheKey)
|
||||
this.executionStatsCache.set(cacheKey, cached)
|
||||
return structuredClone(cached.value)
|
||||
}
|
||||
const result = this.readExecutionStatsSnapshot(scope, activeRequestIds)
|
||||
this.executionStatsCache.delete(cacheKey)
|
||||
this.executionStatsCache.set(cacheKey, {
|
||||
value: structuredClone(result), cachedAt: Date.now(), dataVersion, totalChanges
|
||||
})
|
||||
while (this.executionStatsCache.size > AssistantDatabase.executionStatsCacheLimit) {
|
||||
this.executionStatsCache.delete(this.executionStatsCache.keys().next().value!)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getExecutionStatsAsync(
|
||||
input: ExecutionStatsInput,
|
||||
activeRequestIds: ReadonlySet<string>,
|
||||
workerPath: string
|
||||
): Promise<ExecutionStats> {
|
||||
this.requireDatabase()
|
||||
const scope = executionStatsInputSchema.parse(input)
|
||||
if (this.databasePath === ':memory:') {
|
||||
return Promise.resolve(this.getExecutionStats(scope, activeRequestIds))
|
||||
}
|
||||
this.executionStatsReader ??= new ExecutionStatsReader(this.databasePath, workerPath)
|
||||
return this.executionStatsReader.read(scope, activeRequestIds)
|
||||
}
|
||||
|
||||
openReadOnly(): void {
|
||||
if (this.database) throw new Error('Database already open')
|
||||
this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 })
|
||||
}
|
||||
|
||||
private readExecutionStatsSnapshot(
|
||||
scope: ExecutionStatsInput,
|
||||
activeRequestIds: ReadonlySet<string>
|
||||
): ExecutionStats {
|
||||
const database = this.requireDatabase()
|
||||
if (database.isTransaction) return this.computeExecutionStats(scope, activeRequestIds)
|
||||
database.exec('BEGIN')
|
||||
try {
|
||||
const result = this.computeExecutionStats(scope, activeRequestIds)
|
||||
database.exec('COMMIT')
|
||||
return result
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private computeExecutionStats(
|
||||
scope: ExecutionStatsInput,
|
||||
activeRequestIds: ReadonlySet<string>
|
||||
): ExecutionStats {
|
||||
const database = this.requireDatabase()
|
||||
const result: ExecutionStats = {
|
||||
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
|
||||
activeRequestCount: 0, asOf: Date.now(), taskDurations: []
|
||||
}
|
||||
const database = this.requireDatabase()
|
||||
const scopeId = 'conversationId' in scope ? scope.conversationId : scope.projectId
|
||||
const scopeColumn = 'conversationId' in scope ? 'conversation_id' : 'project_id'
|
||||
const durations = new Map<string, ExecutionStats['taskDurations'][number]>()
|
||||
|
||||
@@ -67,6 +67,58 @@ describe('execution duration evidence', () => {
|
||||
expect(database.getExecutionStats({ conversationId: randomUUID() })).toMatchObject({ durationMs: 0, requestCount: 0 })
|
||||
})
|
||||
|
||||
it('invalidates cached scopes for local messages, remote events and external deletion', () => {
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, projectId, title: 'History', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: 'Retained', state: 'complete', createdAt: epoch }]
|
||||
}])
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(1)
|
||||
const id = request()
|
||||
database.getExecutionStats({ projectId })
|
||||
database.appendRemoteTaskEventOnce({ taskId: id, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'done', payload: { requestId: id, type: 'done' } })
|
||||
expect(database.getExecutionStats({ projectId }).incompleteRequestCount).toBe(1)
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
try {
|
||||
raw.prepare('DELETE FROM messages WHERE conversation_id = ?').run(conversationId)
|
||||
raw.prepare('DELETE FROM tasks WHERE id = ?').run(id)
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
|
||||
expect(database.getExecutionStats({ projectId }).requestCount).toBe(0)
|
||||
} finally { raw.close() }
|
||||
})
|
||||
|
||||
it('does not cache transaction snapshots across rollback, or serve a closed connection', () => {
|
||||
const connection = (database as unknown as { database: DatabaseSync }).database
|
||||
const id = request()
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
|
||||
connection.exec('BEGIN')
|
||||
text(id)
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(1)
|
||||
connection.exec('ROLLBACK')
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
|
||||
database.close()
|
||||
expect(() => database.getExecutionStats({ conversationId })).toThrow()
|
||||
database.initialize(directory)
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds cache scopes, expires idle entries and isolates returned objects', () => {
|
||||
const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare')
|
||||
const reads = (): number => prepare.mock.calls.filter(([sql]) => sql.includes('WITH scoped AS MATERIALIZED')).length
|
||||
try {
|
||||
const first = database.getExecutionStats({ projectId })
|
||||
first.requestCount = 999
|
||||
expect(database.getExecutionStats({ projectId }).requestCount).toBe(0)
|
||||
expect(reads()).toBe(1)
|
||||
for (let index = 0; index < 8; index++) database.getExecutionStats({ conversationId: randomUUID() })
|
||||
database.getExecutionStats({ projectId })
|
||||
expect(reads()).toBe(10)
|
||||
at(31)
|
||||
database.getExecutionStats({ projectId })
|
||||
expect(reads()).toBe(11)
|
||||
} finally { prepare.mockRestore() }
|
||||
})
|
||||
|
||||
it('excludes stable schedules, nested experts, and status-only maintenance but includes scheduled reply requests', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
database.updateTaskStatus(schedule.taskId, 'running')
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { build } from 'esbuild'
|
||||
import { afterAll, beforeAll, expect, it } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { ExecutionStatsReader } from './execution-stats-reader'
|
||||
|
||||
let directory: string
|
||||
let workerPath: string
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-statistics-reader-'))
|
||||
workerPath = process.env.GOODBUDDY_STATS_WORKER_PATH ?? join(directory, 'worker.cjs')
|
||||
if (!process.env.GOODBUDDY_STATS_WORKER_PATH) {
|
||||
await build({ entryPoints: [resolve('src/main/execution-stats-worker.ts')], outfile: workerPath,
|
||||
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
|
||||
}
|
||||
})
|
||||
afterAll(async () => { await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) })
|
||||
|
||||
it('reads committed WAL evidence off-thread without recovering live tasks and refreshes external writes', async () => {
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(directory)
|
||||
const projectId = database.listProjects()[0]!.id
|
||||
const conversationId = randomUUID()
|
||||
const id = randomUUID()
|
||||
database.createTask({ id, projectId, conversationId, title: 'Live', instructions: 'Reply', workMode: 'ask', visible: false })
|
||||
database.appendTaskEvent(id, 'text', { requestId: id, type: 'text', delta: 'Reply' })
|
||||
const sql = new DatabaseSync(path)
|
||||
try {
|
||||
const status = database.getTask(id).status
|
||||
const [conversation, project] = await Promise.all([
|
||||
database.getExecutionStatsAsync({ conversationId }, new Set([id]), workerPath),
|
||||
database.getExecutionStatsAsync({ projectId }, new Set([id]), workerPath)
|
||||
])
|
||||
expect(conversation).toMatchObject({ requestCount: 1, activeRequestCount: 1, incompleteRequestCount: 0 })
|
||||
expect(project.requestCount).toBe(1)
|
||||
expect(database.getTask(id).status).toBe(status)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
expect(await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).toMatchObject({ requestCount: 1, activeRequestCount: 0 })
|
||||
sql.exec('BEGIN IMMEDIATE')
|
||||
sql.prepare('DELETE FROM tasks WHERE id = ?').run(id)
|
||||
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(1)
|
||||
sql.exec('ROLLBACK')
|
||||
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(1)
|
||||
sql.prepare('DELETE FROM tasks WHERE id = ?').run(id)
|
||||
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(0)
|
||||
// A returned result must not leave a read transaction pinning the WAL.
|
||||
expect(sql.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get()?.busy).toBe(0)
|
||||
const pending = database.getExecutionStatsAsync({ projectId }, new Set(), workerPath)
|
||||
const rejection = expect(pending).rejects.toThrow('closed')
|
||||
database.close()
|
||||
await rejection
|
||||
} finally { sql.close(); database.close() }
|
||||
})
|
||||
|
||||
it('rejects worker startup errors and permits the next read to retry', async () => {
|
||||
const reader = new ExecutionStatsReader(join(directory, 'missing.sqlite'), workerPath)
|
||||
try {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await expect(reader.read({ projectId: randomUUID() }, new Set())).rejects.toThrow()
|
||||
}
|
||||
} finally { reader.close() }
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import type { ExecutionStats, ExecutionStatsInput } from '../../shared/assistant-contracts'
|
||||
|
||||
export class ExecutionStatsReader {
|
||||
private worker?: Worker
|
||||
private nextId = 0
|
||||
private readonly pending = new Map<number, {
|
||||
resolve: (value: ExecutionStats) => void
|
||||
reject: (error: Error) => void
|
||||
}>()
|
||||
|
||||
constructor(private readonly databasePath: string, private readonly workerPath: string) {}
|
||||
|
||||
read(scope: ExecutionStatsInput, activeRequestIds: ReadonlySet<string>): Promise<ExecutionStats> {
|
||||
if (!this.worker) {
|
||||
const worker = new Worker(this.workerPath, { workerData: { databasePath: this.databasePath } })
|
||||
this.worker = worker
|
||||
worker.on('message', (message: { id: number; result?: ExecutionStats; error?: string }) => {
|
||||
const request = this.pending.get(message.id)
|
||||
if (!request) return
|
||||
this.pending.delete(message.id)
|
||||
if (message.result) request.resolve(message.result)
|
||||
else request.reject(new Error(message.error ?? 'Statistics query failed'))
|
||||
})
|
||||
const failed = (error: Error): void => {
|
||||
if (this.worker !== worker) return
|
||||
this.worker = undefined
|
||||
for (const request of this.pending.values()) request.reject(error)
|
||||
this.pending.clear()
|
||||
void worker.terminate()
|
||||
}
|
||||
worker.once('error', failed)
|
||||
worker.once('exit', (code) => failed(new Error(`Statistics worker exited (${code})`)))
|
||||
worker.unref()
|
||||
}
|
||||
// Bound outstanding work when a window repeatedly changes scope during a slow read.
|
||||
if (this.pending.size >= 16) return Promise.reject(new Error('Statistics reader is busy'))
|
||||
const id = ++this.nextId
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
this.worker!.postMessage({ id, scope, activeRequestIds: [...activeRequestIds] })
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {
|
||||
const worker = this.worker
|
||||
this.worker = undefined
|
||||
for (const request of this.pending.values()) request.reject(new Error('Statistics reader closed'))
|
||||
this.pending.clear()
|
||||
if (worker) void worker.terminate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import type { ExecutionStatsInput } from '../shared/assistant-contracts'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
|
||||
const database = new AssistantDatabase((workerData as { databasePath: string }).databasePath)
|
||||
database.openReadOnly()
|
||||
parentPort!.on('message', (message: {
|
||||
id: number; scope: ExecutionStatsInput; activeRequestIds: string[]
|
||||
}) => {
|
||||
try {
|
||||
const result = database.getExecutionStats(message.scope, new Set(message.activeRequestIds))
|
||||
parentPort!.postMessage({ id: message.id, result })
|
||||
} catch (error) {
|
||||
parentPort!.postMessage({ id: message.id, error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})
|
||||
parentPort!.on('close', () => database.close())
|
||||
@@ -1324,6 +1324,7 @@ describe('registerIpcHandlers model download source routing', () => {
|
||||
vi.mock('electron', () => ({
|
||||
nativeImage: { createFromBuffer: (buffer: Buffer) => ({ isEmpty: () => false, toPNG: () => buffer }) },
|
||||
app: {
|
||||
getAppPath: vi.fn(() => process.cwd()),
|
||||
getName: vi.fn(() => 'GoodBuddy'),
|
||||
getVersion: vi.fn(() => '0.1.0')
|
||||
},
|
||||
@@ -4382,7 +4383,7 @@ describe('registerIpcHandlers token usage', () => {
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getExecutionStats: vi.fn(() => summary)
|
||||
getExecutionStatsAsync: vi.fn(async () => summary)
|
||||
}
|
||||
const webContents = {
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
@@ -4400,13 +4401,13 @@ describe('registerIpcHandlers token usage', () => {
|
||||
const event = { sender: webContents, senderFrame: webContents.mainFrame }
|
||||
const id = '00000000-0000-4000-8000-000000000301'
|
||||
for (const scope of [{ conversationId: id }, { projectId: id }]) {
|
||||
expect(handler(event, scope)).toBe(summary)
|
||||
expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope, new Set())
|
||||
await expect(handler(event, scope)).resolves.toEqual(summary)
|
||||
expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenLastCalledWith(scope, new Set(), expect.stringContaining('execution-stats-worker.js'))
|
||||
}
|
||||
expect(() => handler(event, {})).toThrow()
|
||||
expect(() => handler(event, { projectId: id, conversationId: id })).toThrow()
|
||||
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow()
|
||||
expect(assistantDatabase.getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
|
||||
+3
-2
@@ -7504,9 +7504,10 @@ export function registerIpcHandlers(
|
||||
})
|
||||
registerHandler(ipcChannels.tasksExecutionStats, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.getExecutionStats(
|
||||
return assistantDatabase.getExecutionStatsAsync(
|
||||
executionStatsInputSchema.parse(input),
|
||||
new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id))
|
||||
new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id)),
|
||||
join(app.getAppPath(), 'out/main/execution-stats-worker.js')
|
||||
)
|
||||
})
|
||||
registerHandler(ipcChannels.tasksSetStatus, (event, input: unknown) => {
|
||||
|
||||
@@ -4372,11 +4372,7 @@ describe("App", () => {
|
||||
const dialog = screen.getByRole("alertdialog", {
|
||||
name: "确认永久删除对话 新对话",
|
||||
});
|
||||
expect(dialog).toHaveTextContent("将永久删除此会话的全部内容");
|
||||
expect(dialog).toHaveTextContent(
|
||||
"如果此会话有正在运行的任务,也会同时停止",
|
||||
);
|
||||
expect(dialog).toHaveTextContent("此操作不可恢复");
|
||||
expect(dialog).not.toHaveTextContent("将永久删除此会话的全部内容");
|
||||
const cancel = screen.getByRole("button", {
|
||||
name: "取消删除对话 新对话",
|
||||
});
|
||||
|
||||
@@ -9373,7 +9373,6 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
disabled={deletingConversationId === conversation.id}
|
||||
icon={<Trash2 aria-hidden="true" size={14} />}
|
||||
message={t("conversation.delete.message")}
|
||||
onCancel={() => setConfirmingConversationId("")}
|
||||
onConfirm={() =>
|
||||
void deleteConversation(conversation.id)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
@@ -86,6 +87,34 @@ function ControlledShell({
|
||||
}
|
||||
|
||||
describe('WorkbarShell', () => {
|
||||
it('preserves task state and latest props while keeping hidden terminal and browser effects live', () => {
|
||||
const stopped = vi.fn()
|
||||
function Panel({ id, value }: { id: string; value: string }): ReactNode {
|
||||
const [count, setCount] = useState(0)
|
||||
useEffect(() => () => stopped(id), [id])
|
||||
return <button onClick={() => setCount(count + 1)}>{id}:{value}:{count}</button>
|
||||
}
|
||||
const props = {
|
||||
instances: [...DEFAULT_WORKBAR_INSTANCES, terminalOne],
|
||||
onActiveInstanceChange: vi.fn(), onCloseInstance: vi.fn(), onCreateInstance: vi.fn(),
|
||||
onResolveTerminalTarget: (): WorkbarTargetRef => ({ type: 'local' })
|
||||
}
|
||||
const panel = (value: string) => (instance: WorkbarTabInstance): ReactNode =>
|
||||
<Panel id={instance.appId} value={value} />
|
||||
const { rerender } = render(<WorkbarShell {...props}
|
||||
activeInstanceId={DEFAULT_WORKBAR_INSTANCES[0]!.id} renderPanel={panel('old')} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tasks:old:0' }))
|
||||
rerender(<WorkbarShell {...props} activeInstanceId={terminalOne.id} renderPanel={panel('new')} />)
|
||||
expect(stopped).toHaveBeenCalledWith('tasks')
|
||||
expect(stopped).not.toHaveBeenCalledWith('terminal')
|
||||
expect(stopped).not.toHaveBeenCalledWith('browser')
|
||||
rerender(<WorkbarShell {...props}
|
||||
activeInstanceId={DEFAULT_WORKBAR_INSTANCES[0]!.id} renderPanel={panel('latest')} />)
|
||||
expect(screen.getByRole('button', { name: 'tasks:latest:1' })).toBeVisible()
|
||||
expect(stopped).not.toHaveBeenCalledWith('terminal')
|
||||
expect(stopped).not.toHaveBeenCalledWith('browser')
|
||||
})
|
||||
|
||||
it('shows overflow controls, scrolls without switching tabs, and hides them after widening', () => {
|
||||
let resize = (): void => {}
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type LucideIcon
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Activity,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
@@ -77,6 +78,15 @@ export type WorkbarShellProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
type WorkbarPanelProps = {
|
||||
instance: WorkbarTabInstance
|
||||
renderPanel: (instance: WorkbarTabInstance) => ReactNode
|
||||
}
|
||||
|
||||
function WorkbarPanel({ instance, renderPanel }: WorkbarPanelProps): React.JSX.Element {
|
||||
return <>{renderPanel(instance)}</>
|
||||
}
|
||||
|
||||
function joinClassNames(...values: (string | false | undefined)[]): string {
|
||||
return values.filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -525,7 +535,11 @@ export function WorkbarShell({
|
||||
role="tabpanel"
|
||||
tabIndex={0}
|
||||
>
|
||||
{renderPanel(instance)}
|
||||
{instance.appId === 'tasks' ? (
|
||||
<Activity mode={!catalogOpen && instance.id === activeInstanceId ? 'visible' : 'hidden'}>
|
||||
<WorkbarPanel instance={instance} renderPanel={renderPanel} />
|
||||
</Activity>
|
||||
) : <WorkbarPanel instance={instance} renderPanel={renderPanel} />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -49,6 +49,16 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('useExecutionStats', () => {
|
||||
it('retains snapshot references for identical cached polling results', async () => {
|
||||
getExecutionStats.mockImplementation(async () => stats(10))
|
||||
const { result } = renderStats()
|
||||
await act(async () => {})
|
||||
const before = result.current
|
||||
await act(async () => { vi.advanceTimersByTime(5_000) })
|
||||
expect(result.current.conversation).toBe(before.conversation)
|
||||
expect(result.current.project).toBe(before.project)
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
it.each(['project', 'conversation'] as const)(
|
||||
'clears the previous %s selection and ignores its late polling result', async (scope) => {
|
||||
const { result, rerender } = renderStats()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { startTransition, useEffect, useState } from 'react'
|
||||
import type { ExecutionStats } from '../../shared/assistant-contracts'
|
||||
|
||||
// Keep timing evidence in Main; never extrapolate a stopped or disconnected run.
|
||||
@@ -26,11 +26,18 @@ export function useExecutionStats(
|
||||
window.goodbuddy.tasks.getExecutionStats({ projectId })
|
||||
])
|
||||
pending = false
|
||||
if (!cancelled) setSnapshot({
|
||||
conversationId, projectId,
|
||||
conversation: conversation.status === 'fulfilled' ? conversation.value : undefined,
|
||||
project: project.status === 'fulfilled' ? project.value : undefined
|
||||
})
|
||||
if (!cancelled) startTransition(() => setSnapshot((current) => {
|
||||
const next = {
|
||||
conversationId, projectId,
|
||||
conversation: conversation.status === 'fulfilled' ? conversation.value : undefined,
|
||||
project: project.status === 'fulfilled' ? project.value : undefined
|
||||
}
|
||||
// IPC clones cached snapshots. Preserve references when their contents are unchanged
|
||||
// so idle polling does not rerender App and rebuild task duration maps.
|
||||
return current.conversationId === conversationId && current.projectId === projectId &&
|
||||
JSON.stringify(current.conversation) === JSON.stringify(next.conversation) &&
|
||||
JSON.stringify(current.project) === JSON.stringify(next.project) ? current : next
|
||||
}))
|
||||
}
|
||||
void refresh()
|
||||
const interval = window.setInterval(() => { void refresh() }, 5_000)
|
||||
|
||||
Reference in New Issue
Block a user