Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions src/apps/shared/src/assistantTurn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,22 @@ export type WorkGroup = {
segments: AssistantTurnSegment[]
}

export type WorkGroupSplit = {
workGroup: WorkGroup | null
finalText: string | null
finalTextIndex: number
tailSegments: AssistantTurnSegment[]
}

/**
* Split segments into work group (pre-final) and final text.
* The last text segment is the final answer; everything before it goes into the work group.
* The last text segment is the final answer; trailing segments stay after it.
* Returns null workGroup when there's nothing meaningful to collapse.
*/
export function splitWorkGroup(
segments: AssistantTurnSegment[],
durationMs: number,
): { workGroup: WorkGroup | null; finalText: string | null } {
): WorkGroupSplit {
// Find the index of the last text segment
let lastTextIndex = -1
for (let i = segments.length - 1; i >= 0; i--) {
Expand All @@ -57,22 +64,25 @@ export function splitWorkGroup(

// No text segment at all
if (lastTextIndex === -1) {
return { workGroup: null, finalText: null }
return { workGroup: null, finalText: null, finalTextIndex: -1, tailSegments: [] }
}

const finalSegment = segments[lastTextIndex]!
const finalText = finalSegment.type === 'text' ? finalSegment.content : null
const preSegments = segments.slice(0, lastTextIndex)
const tailSegments = segments.slice(lastTextIndex + 1)

// Need at least 2 segments before final to justify a work group.
// A single pre-final segment (text or cop) stays inline.
if (lastTextIndex < 2) {
return { workGroup: null, finalText }
if (preSegments.length < 2) {
return { workGroup: null, finalText, finalTextIndex: lastTextIndex, tailSegments }
}

const workGroupSegments = segments.slice(0, lastTextIndex)
return {
workGroup: { durationMs, segments: workGroupSegments },
workGroup: { durationMs, segments: preSegments },
finalText,
finalTextIndex: lastTextIndex,
tailSegments,
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/apps/shared/src/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,18 @@ export type DesktopExportSection =
| 'themes'

export type DesktopThemeExportPayload = {
themePreset?: string | null
customThemeId: string | null
customThemes: Record<string, unknown>
backgroundImage?: {
dataUrl: string
name: string
mimeType: string
size: number
updatedAt: number
} | null
backgroundImageOpacity?: number | null
sidebarGrouping?: 'normal' | 'gtd' | null
}

export type DesktopExportOptions = {
Expand Down
44 changes: 44 additions & 0 deletions src/apps/web/src/__tests__/advancedSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ describe('AdvancedSettings', () => {
return {
...actual,
readLocaleFromStorage: vi.fn(() => 'zh'),
readGtdEnabled: vi.fn(() => false),
writeLocaleToStorage: vi.fn(),
}
})
Expand Down Expand Up @@ -140,10 +141,16 @@ describe('AdvancedSettings', () => {
}))
vi.doMock('../contexts/AppearanceContext', () => ({
useAppearance: () => ({
themePreset: 'default',
setThemePreset: vi.fn(),
customThemeId: null,
customThemes: {},
saveCustomTheme: vi.fn(),
setActiveCustomTheme: vi.fn(),
backgroundImage: null,
setBackgroundImage: vi.fn(() => true),
backgroundImageOpacity: 40,
setBackgroundImageOpacity: vi.fn(),
}),
}))
vi.doMock('@arkloop/shared', async () => {
Expand Down Expand Up @@ -228,6 +235,7 @@ describe('AdvancedSettings', () => {
return {
...actual,
readLocaleFromStorage: vi.fn(() => 'zh'),
readGtdEnabled: vi.fn(() => true),
writeLocaleToStorage: vi.fn(),
}
})
Expand Down Expand Up @@ -255,10 +263,22 @@ describe('AdvancedSettings', () => {
}))
vi.doMock('../contexts/AppearanceContext', () => ({
useAppearance: () => ({
themePreset: 'background-image',
setThemePreset: vi.fn(),
customThemeId: null,
customThemes: {},
saveCustomTheme: vi.fn(),
setActiveCustomTheme: vi.fn(),
backgroundImage: {
dataUrl: 'data:image/png;base64,Ymc=',
name: 'bg.png',
mimeType: 'image/png',
size: 2,
updatedAt: 1234,
},
setBackgroundImage: vi.fn(() => true),
backgroundImageOpacity: 55,
setBackgroundImageOpacity: vi.fn(),
}),
}))
vi.doMock('@arkloop/shared', async () => {
Expand Down Expand Up @@ -311,5 +331,29 @@ describe('AdvancedSettings', () => {
'自定义主题',
])
expect(exportDataBundle).not.toHaveBeenCalled()

const exportLabel = exportButton!.textContent?.trim()
const confirmExportButton = Array.from(document.body.querySelectorAll('button'))
.filter((button) => button.textContent?.trim() === exportLabel)
.at(-1)
expect(confirmExportButton).toBeTruthy()

await act(async () => {
confirmExportButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
await flushEffects()

expect(exportDataBundle).toHaveBeenCalledWith(expect.objectContaining({
sections: expect.arrayContaining(['themes']),
themes: expect.objectContaining({
themePreset: 'background-image',
backgroundImage: expect.objectContaining({
dataUrl: 'data:image/png;base64,Ymc=',
name: 'bg.png',
}),
backgroundImageOpacity: 55,
sidebarGrouping: 'gtd',
}),
}))
})
})
4 changes: 2 additions & 2 deletions src/apps/web/src/__tests__/appUI.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ describe('DesktopTitleBar update entry', () => {
await renderTitleBar(appUpdateState('available'), true)

const titleBar = container.firstElementChild as HTMLElement | null
expect(titleBar?.style.paddingLeft).toBe('8px')
expect(titleBar?.style.paddingLeft).toBe('12px')
expect(container.querySelector('button[title="Minimize"]')).toBeNull()
})

Expand All @@ -394,7 +394,7 @@ describe('DesktopTitleBar update entry', () => {
await renderTitleBar(appUpdateState('available'), true)

const titleBar = container.firstElementChild as HTMLElement | null
expect(titleBar?.style.paddingLeft).toBe('8px')
expect(titleBar?.style.paddingLeft).toBe('12px')
expect(container.querySelector('button[title="Minimize"]')).toBeNull()
})

Expand Down
18 changes: 18 additions & 0 deletions src/apps/web/src/__tests__/appearanceStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import type { ThemeBackgroundImage } from '../themes/types'
import {
readBackgroundImageFromStorage,
readBackgroundImageOpacityFromStorage,
readGtdEnabled,
readThemePresetFromStorage,
subscribeGtdEnabled,
writeBackgroundImageToStorage,
writeBackgroundImageOpacityToStorage,
writeGtdEnabled,
writeThemePresetToStorage,
} from '../storage'

Expand Down Expand Up @@ -105,4 +108,19 @@ describe('appearance storage', () => {
writeThemePresetToStorage('background-image')
expect(readThemePresetFromStorage()).toBe('background-image')
})

it('同步 GTD 分组状态变更', () => {
const observed: boolean[] = []
const unsubscribe = subscribeGtdEnabled((enabled) => observed.push(enabled))

writeGtdEnabled(true)
expect(readGtdEnabled()).toBe(true)
expect(observed).toEqual([true])

writeGtdEnabled(false)
expect(readGtdEnabled()).toBe(false)
expect(observed).toEqual([true, false])

unsubscribe()
})
})
61 changes: 61 additions & 0 deletions src/apps/web/src/__tests__/assistantTurnSegments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
finalizeAssistantTurnFoldState,
foldAssistantTurnEvent,
requestAssistantTurnThinkingBreak,
splitWorkGroup,
} from '../assistantTurnSegments'
import {
normalizeAgentEventData,
Expand Down Expand Up @@ -46,6 +47,66 @@ function th(content: string, seq: number, endedByEventSeq?: number) {
return { kind: 'thinking' as const, content, seq, startedAtMs, endedAtMs }
}

describe('splitWorkGroup', () => {
it('keeps a trailing tool segment after final text', () => {
const tailToolSegment = {
type: 'cop' as const,
title: null,
items: [{
kind: 'call' as const,
call: { toolCallId: 'terminal_1', toolName: 'terminal_run', arguments: {} },
seq: 2,
}],
}

const split = splitWorkGroup([
{ type: 'text', content: 'done' },
tailToolSegment,
], 1200)

expect(split.finalText).toBe('done')
expect(split.finalTextIndex).toBe(0)
expect(split.workGroup).toBeNull()
expect(split.tailSegments).toEqual([tailToolSegment])
})

it('collapses pre-final work while preserving trailing segment order', () => {
const firstToolSegment = {
type: 'cop' as const,
title: null,
items: [{
kind: 'call' as const,
call: { toolCallId: 'terminal_1', toolName: 'terminal_run', arguments: {} },
seq: 2,
}],
}
const tailToolSegment = {
type: 'cop' as const,
title: null,
items: [{
kind: 'call' as const,
call: { toolCallId: 'terminal_2', toolName: 'terminal_run', arguments: {} },
seq: 4,
}],
}

const split = splitWorkGroup([
{ type: 'text', content: 'before' },
firstToolSegment,
{ type: 'text', content: 'done' },
tailToolSegment,
], 1200)

expect(split.finalText).toBe('done')
expect(split.finalTextIndex).toBe(2)
expect(split.workGroup?.segments).toEqual([
{ type: 'text', content: 'before' },
firstToolSegment,
])
expect(split.tailSegments).toEqual([tailToolSegment])
})
})

describe('buildAssistantTurnFromAgentEvents', () => {
beforeEach(() => {
vi.useFakeTimers()
Expand Down
51 changes: 12 additions & 39 deletions src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ describe('ChatInput persona selector', () => {
}
})

it('按动态列表循环切换并可从下拉选择人格', async () => {
it('不再从加号菜单加载动态人格,提交沿用已选人格', async () => {
const onSubmit = vi.fn<(event: FormEvent<HTMLFormElement>, personaKey: string) => void>((event) => event.preventDefault())
const container = document.createElement('div')
document.body.appendChild(container)
Expand All @@ -148,49 +148,22 @@ describe('ChatInput persona selector', () => {
await flushMicrotasks()
})

expect(mockedListSelectablePersonas).toHaveBeenCalledWith('token')

const selectorButton = findButtonByText(container, 'Normal')
expect(selectorButton).not.toBeNull()
if (!selectorButton) return

await act(async () => {
selectorButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})

const searchMenuButton = Array.from(container.querySelectorAll('button')).find(
(button) => button !== selectorButton && button.textContent?.trim() === 'Search',
) as HTMLButtonElement | null
expect(searchMenuButton).not.toBeNull()
if (!searchMenuButton) return

await act(async () => {
searchMenuButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})

expect(findButtonByText(container, 'Search')).not.toBeNull()

const searchSelectorButton = findButtonByText(container, 'Search')
expect(searchSelectorButton).not.toBeNull()
if (!searchSelectorButton) return

await act(async () => {
searchSelectorButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
const form = container.querySelector('form')
expect(form).not.toBeNull()
if (!form) return

const menuNormalButton = Array.from(container.querySelectorAll('button')).find(
(button) => button !== searchSelectorButton && button.textContent?.trim() === 'Normal',
) as HTMLButtonElement | null
expect(menuNormalButton).not.toBeNull()
if (!menuNormalButton) return
const menuButton = form.querySelector<HTMLButtonElement>('button[type="button"]')
expect(menuButton).not.toBeNull()
if (!menuButton) return

await act(async () => {
menuNormalButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
menuButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushMicrotasks()
})

const form = container.querySelector('form')
expect(form).not.toBeNull()
if (!form) return
expect(mockedListSelectablePersonas).not.toHaveBeenCalled()
expect(findButtonByText(container, 'Normal')).toBeFalsy()
expect(findButtonByText(container, 'Search')).toBeFalsy()

await act(async () => {
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
Expand Down
7 changes: 6 additions & 1 deletion src/apps/web/src/__tests__/chatPageLoading.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,10 @@ describe('ChatPage loading state', () => {
mockedReadMessageTerminalStatus.mockReturnValue(null)
})

afterEach(() => {
afterEach(async () => {
sseMock.clearEventListeners()
sseMock.events = []
await flushMicrotasks()
HTMLElement.prototype.scrollIntoView = originalScrollIntoView
if (originalActEnvironment === undefined) {
delete actEnvironment.IS_REACT_ACT_ENVIRONMENT
Expand Down Expand Up @@ -3740,6 +3743,7 @@ describe('ChatPage loading state', () => {
expect(container.textContent ?? '').toContain('streaming')
expect(sseMock.reset).toHaveBeenCalled()
expect(sseMock.connect).toHaveBeenCalled()
expect(sseMock.subscribeEvents).toHaveBeenCalled()
})

await act(async () => {
Expand Down Expand Up @@ -3792,6 +3796,7 @@ describe('ChatPage loading state', () => {
root.render(renderTree())
await flushMicrotasks()
await flushMicrotasks()
await flushAnimationFrames(2)
})

await waitForAssertion(() => {
Expand Down
Loading
Loading