release: prepare Desktop 0.13.10

This commit is contained in:
mesalogo
2026-09-21 22:53:44 +08:00
parent 6c4c054180
commit a0b35d1192
24 changed files with 677 additions and 30 deletions
+1 -5
View File
@@ -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: "取消删除对话 新对话",
});
-1
View File
@@ -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)
+29
View File
@@ -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 {
+15 -1
View File
@@ -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()
+13 -6
View File
@@ -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)