mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
Show changed files directly in chat turns (#38256)
* Show changed files directly in chat turns * Use the fixture frame for the diff interaction * Keep changed filenames readable and preserve file actions * Keep unconfirmed file changes in tool activity * Capture changed files in completed transcript fixtures * Preserve patch data with authoritative history status * Keep mobile diff interaction coverage beside its card * Assert the decoded patch line contract * Exercise full returned history in catch-up coverage * test(chat): target file summary when reopening patches
This commit is contained in:
@@ -29,9 +29,9 @@
|
||||
"workspaceHostAmbiguous": "Its workspace spans more than one host, so it has no single owner."
|
||||
},
|
||||
"runFailures": {
|
||||
"agentInterrupted": "The agent was interrupted before it finished this run.",
|
||||
"agentNeverStarted": "Automation agent never started after the prompt was submitted.",
|
||||
"agentNoCompletion": "Automation agent did not report completion.",
|
||||
"agentInterrupted": "The agent was interrupted before it finished this run.",
|
||||
"automationChanged": "The automation changed before this run could launch.",
|
||||
"destinationChanged": "The automation destination changed before this run could launch.",
|
||||
"hooksReviewBlocked": "The agent is waiting for someone to review new or changed hooks (\"Hooks need review\"). Open this run and trust them, or remove them. Scheduled runs keep going and succeed once nothing needs review.",
|
||||
@@ -152,6 +152,12 @@
|
||||
"needsYou_one": "{{count}} needs you",
|
||||
"needsYou_other": "{{count}} need you"
|
||||
},
|
||||
"native-chat": {
|
||||
"turnDiff": {
|
||||
"changedFiles_one": "{{count}} changed file",
|
||||
"changedFiles_other": "{{count}} changed files"
|
||||
}
|
||||
},
|
||||
"worktreeAgentList": {
|
||||
"hideChildAgents_one": "Hide {{count}} child agent",
|
||||
"hideChildAgents_other": "Hide {{count}} child agents",
|
||||
|
||||
@@ -1377,6 +1377,14 @@
|
||||
"noMatches": "No files match",
|
||||
"current": "current",
|
||||
"viewedFile": "{{path}} viewed"
|
||||
},
|
||||
"native-chat": {
|
||||
"turnDiff": {
|
||||
"changedFiles_one": "{{count}} changed file",
|
||||
"changedFiles_other": "{{count}} changed files",
|
||||
"partial": "Partial diff",
|
||||
"showMoreFiles": "Show {{count}} more"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pullRequest": {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import './transcript/mobile-agent-message-renderer.test-fixture'
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { findAllByHostType } from '../../test-support/react-test-renderer-host-nodes'
|
||||
import { Transcript, textIn } from './transcript/mobile-native-chat-turn-diff.test-fixture'
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('shows finished-turn files outside folded work and opens a patch with one tap', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(Transcript, { working: true }))
|
||||
})
|
||||
expect(textIn(renderer!.root)).toContain('greeting.ts')
|
||||
act(() => {
|
||||
renderer!.update(createElement(Transcript))
|
||||
})
|
||||
expect(textIn(renderer!.root)).toContain('greeting.ts')
|
||||
expect(textIn(renderer!.root)).not.toContain('diagnostic-output')
|
||||
expect(textIn(renderer!.root)).not.toContain('helloNew()')
|
||||
const file = findAllByHostType(renderer!.root, 'Pressable').find(
|
||||
(node) => node.props.accessibilityLabel === '/repo/greeting.ts'
|
||||
)!
|
||||
act(() => file.props.onPress())
|
||||
expect(textIn(renderer!.root)).toContain('helloNew()')
|
||||
expect(textIn(renderer!.root)).not.toContain('statusNew()')
|
||||
act(() => renderer!.unmount())
|
||||
act(() => {
|
||||
renderer = create(createElement(Transcript))
|
||||
})
|
||||
expect(textIn(renderer!.root)).toContain('status.ts')
|
||||
})
|
||||
@@ -130,19 +130,21 @@ export function MobileNativeChatDiffCard({
|
||||
fontScale,
|
||||
disclosureKey,
|
||||
rootIdentity,
|
||||
initialOpen = false,
|
||||
onOpenFile
|
||||
}: {
|
||||
file: NativeChatEditFile
|
||||
fontScale: number
|
||||
disclosureKey?: string
|
||||
rootIdentity?: string
|
||||
initialOpen?: boolean
|
||||
onOpenFile?: (path: string) => void
|
||||
}): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const copy = nativeChatDiffCardCopy(t)
|
||||
const verbs = nativeChatToolRowVerbCopy(t)
|
||||
const [expanded, setExpanded] = useMobileNativeChatDisclosure(disclosureKey, false)
|
||||
const [expanded, setExpanded] = useMobileNativeChatDisclosure(disclosureKey, initialOpen)
|
||||
const hasBody = file.lines.length > 0
|
||||
const copyText = useMemo(() => nativeChatEditFilePatchText(file.lines), [file.lines])
|
||||
const type = toolType(fontScale)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
sameNativeChatTurnDiff,
|
||||
type NativeChatTurnDiff
|
||||
} from '../../../../../src/shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import { MobileNativeChatTurnDiffRollup } from './MobileNativeChatTurnDiffRollup'
|
||||
import { memo, useContext, useMemo, type ComponentProps } from 'react'
|
||||
import {
|
||||
MobileNativeChatItemSourceContext,
|
||||
@@ -23,13 +28,23 @@ type Recovery = MobileNativeChatVisualSource & {
|
||||
}
|
||||
export type MobileNativeChatRowContentProps = ComponentProps<typeof MobileNativeChatMessage> & {
|
||||
scope: string
|
||||
turnDiff?: NativeChatTurnDiff
|
||||
source: Recovery | null
|
||||
}
|
||||
|
||||
const TurnDiffOutcome = memo(
|
||||
MobileNativeChatTurnDiffRollup,
|
||||
(previous, next) =>
|
||||
sameNativeChatTurnDiff(previous.diff, next.diff) &&
|
||||
previous.fontScale === next.fontScale &&
|
||||
previous.onOpenFile === next.onOpenFile
|
||||
)
|
||||
|
||||
// React's mounted content props are the sole reuse baseline.
|
||||
export const MobileNativeChatRowContent = memo(function MobileNativeChatRowContent({
|
||||
source,
|
||||
scope: _scope,
|
||||
turnDiff,
|
||||
...props
|
||||
}: MobileNativeChatRowContentProps) {
|
||||
const value = useMemo(() => {
|
||||
@@ -50,6 +65,13 @@ export const MobileNativeChatRowContent = memo(function MobileNativeChatRowConte
|
||||
return (
|
||||
<MobileNativeChatItemSourceContext.Provider value={value}>
|
||||
<MobileNativeChatMessage {...props} />
|
||||
{turnDiff ? (
|
||||
<TurnDiffOutcome
|
||||
diff={turnDiff}
|
||||
fontScale={props.fontScale ?? 1}
|
||||
onOpenFile={props.onOpenFile}
|
||||
/>
|
||||
) : null}
|
||||
</MobileNativeChatItemSourceContext.Provider>
|
||||
)
|
||||
}, equalMobileNativeChatRowContent)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import './mobile-agent-message-renderer.test-fixture'
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { messages, Transcript } from './mobile-native-chat-turn-diff.test-fixture'
|
||||
import { MobileNativeChatTurnDiffRollup } from './MobileNativeChatTurnDiffRollup'
|
||||
import type * as TurnDiffRollupModule from './MobileNativeChatTurnDiffRollup'
|
||||
|
||||
vi.mock('./MobileNativeChatTurnDiffRollup', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof TurnDiffRollupModule>()
|
||||
return { ...actual, MobileNativeChatTurnDiffRollup: vi.fn(actual.MobileNativeChatTurnDiffRollup) }
|
||||
})
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it.each([10, 100])(
|
||||
'does not repaint unrelated outcome rows while another turn streams (%i turns)',
|
||||
(count) => {
|
||||
const rows = Array.from({ length: count }, (_, index) =>
|
||||
messages.map((message) => ({
|
||||
...message,
|
||||
id: `${index}:${message.id}`
|
||||
}))
|
||||
).flat()
|
||||
act(() => {
|
||||
renderer = create(createElement(Transcript, { rows }))
|
||||
})
|
||||
const outcome = vi.mocked(MobileNativeChatTurnDiffRollup)
|
||||
const before = outcome.mock.calls.length
|
||||
const last = rows.at(-1)!
|
||||
const updated = [
|
||||
...rows.slice(0, -1),
|
||||
{ ...last, blocks: [{ type: 'text' as const, text: 'Updated again.' }] }
|
||||
]
|
||||
act(() => {
|
||||
renderer!.update(createElement(Transcript, { rows: updated }))
|
||||
})
|
||||
expect(outcome.mock.calls.length - before).toBe(0)
|
||||
const changed = updated.map((message) =>
|
||||
message.id === `${count - 1}:edit`
|
||||
? {
|
||||
...message,
|
||||
blocks: message.blocks.map((block) =>
|
||||
block.type === 'tool-call' && block.name === 'Edit'
|
||||
? {
|
||||
...block,
|
||||
input: {
|
||||
file_path: '/repo/greeting.ts',
|
||||
old_string: 'helloOld()',
|
||||
new_string: 'helloNewest()'
|
||||
}
|
||||
}
|
||||
: block
|
||||
)
|
||||
}
|
||||
: message
|
||||
)
|
||||
act(() => {
|
||||
renderer!.update(createElement(Transcript, { rows: changed }))
|
||||
})
|
||||
expect(outcome.mock.calls.length - before).toBe(1)
|
||||
const latest = outcome.mock.calls.at(-1)![0].diff
|
||||
expect(latest.files.map((file) => file.path)).toContain('/repo/greeting.ts')
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { ChevronRight } from 'lucide-react-native'
|
||||
import {
|
||||
NATIVE_CHAT_TURN_DIFF_FILE_LIMIT,
|
||||
nativeChatTurnDiffEditFile,
|
||||
type NativeChatTurnDiff,
|
||||
type NativeChatTurnDiffFile
|
||||
} from '../../../../../src/shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import { MobileNativeChatDiffCard } from '../MobileNativeChatDiffCard'
|
||||
import { useMobileNativeChatDisclosure } from './mobile-native-chat-disclosure-store'
|
||||
import { useMobileTranslation } from '../../../i18n/use-mobile-translation'
|
||||
import { useMobileColorScheme } from '../../../theme/use-mobile-color-scheme'
|
||||
import { colors, pressedStyle, radii, spacing } from '../../../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../../../theme/themed-styles'
|
||||
import { toolType } from './mobile-native-chat-tool-styles'
|
||||
import { nativeChatEditFileBaseName } from '../../../../../src/shared/native-chat/tools/native-chat-edit-model'
|
||||
|
||||
function FileRow({
|
||||
file,
|
||||
fontScale,
|
||||
onOpenFile
|
||||
}: {
|
||||
file: NativeChatTurnDiffFile
|
||||
fontScale: number
|
||||
onOpenFile?: (path: string) => void
|
||||
}): React.JSX.Element {
|
||||
const [open, setOpen] = useMobileNativeChatDisclosure(
|
||||
`turn-diff:${file.edits[0]?.target.messageId}:${file.path}`,
|
||||
false
|
||||
)
|
||||
const type = toolType(fontScale)
|
||||
const name = nativeChatEditFileBaseName(file.path)
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.file, pressed && pressedStyle]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={file.path}
|
||||
accessibilityState={{ expanded: open }}
|
||||
onPress={() => setOpen(!open)}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14 * fontScale}
|
||||
color={colors.textMuted}
|
||||
style={open ? styles.open : undefined}
|
||||
/>
|
||||
<Text style={[styles.path, type.meta]} numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text style={[styles.directory, type.meta]} numberOfLines={1}>
|
||||
{file.path.slice(0, -name.length)}
|
||||
</Text>
|
||||
<Text style={[styles.added, type.meta]}>+{file.added}</Text>
|
||||
<Text style={[styles.removed, type.meta]}>-{file.removed}</Text>
|
||||
</Pressable>
|
||||
{open
|
||||
? file.edits.map((edit) => {
|
||||
const patch = nativeChatTurnDiffEditFile(edit)
|
||||
const key = `turn-diff-card:${edit.target.messageId}:${edit.target.editKey}:${edit.fileIndex}`
|
||||
return patch ? (
|
||||
<MobileNativeChatDiffCard
|
||||
key={key}
|
||||
file={patch}
|
||||
fontScale={fontScale}
|
||||
initialOpen
|
||||
disclosureKey={key}
|
||||
rootIdentity={key}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
) : null
|
||||
})
|
||||
: null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function MobileNativeChatTurnDiffRollup({
|
||||
diff,
|
||||
fontScale,
|
||||
onOpenFile
|
||||
}: {
|
||||
diff: NativeChatTurnDiff
|
||||
fontScale: number
|
||||
onOpenFile?: (path: string) => void
|
||||
}): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const [showAll, setShowAll] = useMobileNativeChatDisclosure(
|
||||
`turn-diff-files:${diff.files[0]?.edits[0]?.target.messageId}`,
|
||||
false
|
||||
)
|
||||
const files = showAll ? diff.files : diff.files.slice(0, NATIVE_CHAT_TURN_DIFF_FILE_LIMIT)
|
||||
const remaining = diff.files.length - files.length
|
||||
const type = toolType(fontScale)
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.label, type.meta]}>
|
||||
{t('components.native-chat.turnDiff.changedFiles', '{{count}} changed files', {
|
||||
count: diff.files.length,
|
||||
defaultValue_one: '{{count}} changed file'
|
||||
})}
|
||||
</Text>
|
||||
<Text style={[styles.added, type.meta]}>+{diff.added}</Text>
|
||||
<Text style={[styles.removed, type.meta]}>-{diff.removed}</Text>
|
||||
{diff.truncated ? (
|
||||
<Text style={[styles.label, type.meta]}>
|
||||
{t('components.native-chat.turnDiff.partial', 'Partial diff')}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{files.map((file) => (
|
||||
<FileRow key={file.path} file={file} fontScale={fontScale} onOpenFile={onOpenFile} />
|
||||
))}
|
||||
{remaining > 0 ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [styles.file, pressed && pressedStyle]}
|
||||
onPress={() => setShowAll(true)}
|
||||
>
|
||||
<Text style={[styles.label, type.meta]}>
|
||||
{t('components.native-chat.turnDiff.showMoreFiles', 'Show {{count}} more', {
|
||||
count: remaining
|
||||
})}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = themedStyleSheet(() => ({
|
||||
card: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.surface,
|
||||
padding: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
marginHorizontal: spacing.lg,
|
||||
gap: spacing.xs
|
||||
},
|
||||
header: { flexDirection: 'row', alignItems: 'center', flexWrap: 'wrap', gap: spacing.sm },
|
||||
file: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
path: { maxWidth: '65%', flexShrink: 1, color: colors.textPrimary },
|
||||
directory: { flex: 1, color: colors.textMuted },
|
||||
label: { color: colors.textMuted },
|
||||
added: { color: colors.gitDecorationAdded },
|
||||
removed: { color: colors.gitDecorationDeleted },
|
||||
open: { transform: [{ rotate: '90deg' }] }
|
||||
}))
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sameNativeChatTurnDiff } from '../../../../../src/shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import type {
|
||||
NativeChatBlock,
|
||||
NativeChatMessage
|
||||
@@ -49,6 +50,9 @@ export function equalMobileNativeChatRowContent(
|
||||
if (Object.is(x, y)) {
|
||||
return true
|
||||
}
|
||||
if (key === 'turnDiff') {
|
||||
return sameNativeChatTurnDiff(a.turnDiff, b.turnDiff)
|
||||
}
|
||||
if (key === 'message') {
|
||||
return sameMessage(a.message, b.message)
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import './mobile-agent-message-renderer.test-fixture'
|
||||
import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types'
|
||||
import type { ReactTestInstance } from 'react-test-renderer'
|
||||
import { vi } from 'vitest'
|
||||
import { useMobileNativeChatTurnDisclosure } from './use-mobile-native-chat-turn-disclosure'
|
||||
import { MobileNativeChatRowContent } from './MobileNativeChatRowItemSource'
|
||||
import { findAllByHostType } from '../../../test-support/react-test-renderer-host-nodes'
|
||||
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: { getItem: async () => null, setItem: async () => {}, removeItem: async () => {} }
|
||||
}))
|
||||
|
||||
export const messages: NativeChatMessage[] = [
|
||||
{
|
||||
id: 'u',
|
||||
role: 'user',
|
||||
source: 'transcript',
|
||||
timestamp: 1,
|
||||
blocks: [{ type: 'text', text: 'Fix the greeting' }]
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
role: 'assistant',
|
||||
source: 'transcript',
|
||||
timestamp: 2,
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
input: { command: 'inspect-diagnostic' }
|
||||
},
|
||||
{ type: 'tool-result', output: 'diagnostic-output' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'Edit',
|
||||
state: 'completed',
|
||||
input: {
|
||||
file_path: '/repo/greeting.ts',
|
||||
old_string: 'helloOld()',
|
||||
new_string: 'helloNew()'
|
||||
}
|
||||
},
|
||||
{ type: 'tool-result', output: 'The file /repo/greeting.ts has been updated successfully.' },
|
||||
{ type: 'tool-call', name: 'Diff', input: { path: 'status.ts' } },
|
||||
{ type: 'tool-result', output: '@@ -1 +1 @@\n-statusOld()\n+statusNew()' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'answer',
|
||||
role: 'assistant',
|
||||
source: 'transcript',
|
||||
timestamp: 3,
|
||||
blocks: [{ type: 'text', text: 'Updated the greeting.' }]
|
||||
}
|
||||
]
|
||||
const settled = new Map([['u', { startedAt: 1, workedSeconds: 2 }]])
|
||||
export function Transcript({
|
||||
rows = messages,
|
||||
working = false
|
||||
}: {
|
||||
rows?: NativeChatMessage[]
|
||||
working?: boolean
|
||||
}) {
|
||||
const turns = useMobileNativeChatTurnDisclosure({
|
||||
messages: rows,
|
||||
enabled: true,
|
||||
isWorking: working,
|
||||
settledTurns: working ? undefined : settled,
|
||||
scopeKey: 'test'
|
||||
})
|
||||
return (
|
||||
<>
|
||||
{turns.listMessages.map((message, index) => (
|
||||
<MobileNativeChatRowContent
|
||||
key={message.id}
|
||||
{...turns.resolveRow(index, message)}
|
||||
message={message}
|
||||
structuredActivityUi
|
||||
source={null}
|
||||
scope="test"
|
||||
onToggleTurn={turns.onToggleTurn}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export function textIn(node: ReactTestInstance): string[] {
|
||||
return findAllByHostType(node, 'Text').map((text) => String(text.children.join('')))
|
||||
}
|
||||
@@ -5,7 +5,32 @@ import type {
|
||||
import type { NativeChatTaskListPredecessors } from '../../../../../src/shared/native-chat/input/native-chat-task-list-history'
|
||||
import type { NativeChatResolvedPrompt } from '../../../../../src/shared/native-chat/native-chat-resolution-receipt'
|
||||
import { isActionableStructuredAgentSessionPrompt } from '../../../../../src/shared/agent-session/view/structured-agent-session-live-turn'
|
||||
import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-turn-disclosure'
|
||||
import type { NativeChatTurnStatus } from '../../../../../src/shared/native-chat/transcript/native-chat-turn-status'
|
||||
import type { NativeChatTurnDiff } from '../../../../../src/shared/native-chat/tools/native-chat-turn-diffs'
|
||||
|
||||
export type MobileNativeChatTurnRow = {
|
||||
turnDiff?: NativeChatTurnDiff
|
||||
turnStatus: NativeChatTurnStatus | null
|
||||
turnStatusAbove?: boolean
|
||||
turnExpanded: boolean
|
||||
turnKey?: string
|
||||
activeTurnIsWorking: boolean
|
||||
trailingRun: boolean
|
||||
workRun?: readonly NativeChatMessage[]
|
||||
joinsWorkRun?: boolean
|
||||
foldedAway?: boolean
|
||||
openReasoning?: ReadonlySet<string>
|
||||
mayStillGrow: boolean
|
||||
reasoningIsLive: boolean
|
||||
reasoningExpanded: boolean
|
||||
onToggleReasoning: (key: string) => void
|
||||
// Pass roster disclosure only to roster rows so toggles cannot rerender every transcript row.
|
||||
subagentGroupsOpen?: ReadonlySet<string>
|
||||
onToggleSubagentGroup: (groupId: string) => void
|
||||
// Hold expanded user messages above recycled rows so scrolling away cannot collapse them.
|
||||
userMessageOpen: boolean
|
||||
onToggleUserMessage: (messageId: string) => void
|
||||
}
|
||||
|
||||
/** The message and its derived render inputs share one reuse boundary. */
|
||||
export type MobileNativeChatRowProps = MobileNativeChatTurnRow & {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types'
|
||||
import { nativeChatTurnMembership } from '../../../../../src/shared/native-chat/transcript/native-chat-turn-membership'
|
||||
import { nativeChatTurnDiffs } from '../../../../../src/shared/native-chat/tools/native-chat-turn-diffs'
|
||||
|
||||
export function useMobileNativeChatTurnDiffs(
|
||||
rows: readonly NativeChatMessage[],
|
||||
structured: boolean,
|
||||
turnKeys: readonly (string | undefined)[],
|
||||
partialTurnKey: string | undefined
|
||||
) {
|
||||
return useMemo(() => {
|
||||
const keys = structured ? turnKeys : nativeChatTurnMembership(rows, null).turnKeys
|
||||
const diffs = nativeChatTurnDiffs(rows, keys, undefined, partialTurnKey)
|
||||
const lastRows = new Map<string, string>()
|
||||
rows.forEach((row, index) => {
|
||||
const key = keys[index]
|
||||
if (key !== undefined) {
|
||||
lastRows.set(key, row.id)
|
||||
}
|
||||
})
|
||||
return new Map(Array.from(lastRows, ([key, id]) => [id, diffs.get(key)]))
|
||||
}, [structured, partialTurnKey, rows, turnKeys])
|
||||
}
|
||||
+9
-25
@@ -1,3 +1,5 @@
|
||||
import { useMobileNativeChatTurnDiffs } from './use-mobile-native-chat-turn-diffs'
|
||||
import type { MobileNativeChatTurnRow } from './use-mobile-native-chat-row-props'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
isSubagentGroupBlock,
|
||||
@@ -32,29 +34,6 @@ const EMPTY_TURN_IDS: ReadonlySet<string> = new Set()
|
||||
const NO_TURN_KEYS: readonly undefined[] = []
|
||||
const MAX_EXPANDED_TURNS = 128
|
||||
|
||||
export type MobileNativeChatTurnRow = {
|
||||
turnStatus: NativeChatTurnStatus | null
|
||||
turnStatusAbove?: boolean
|
||||
turnExpanded: boolean
|
||||
turnKey?: string
|
||||
activeTurnIsWorking: boolean
|
||||
trailingRun: boolean
|
||||
workRun?: readonly NativeChatMessage[]
|
||||
joinsWorkRun?: boolean
|
||||
foldedAway?: boolean
|
||||
openReasoning?: ReadonlySet<string>
|
||||
mayStillGrow: boolean
|
||||
reasoningIsLive: boolean
|
||||
reasoningExpanded: boolean
|
||||
onToggleReasoning: (key: string) => void
|
||||
// Pass roster disclosure only to roster rows so toggles cannot rerender every transcript row.
|
||||
subagentGroupsOpen?: ReadonlySet<string>
|
||||
onToggleSubagentGroup: (groupId: string) => void
|
||||
// Hold expanded user messages above recycled rows so scrolling away cannot collapse them.
|
||||
userMessageOpen: boolean
|
||||
onToggleUserMessage: (messageId: string) => void
|
||||
}
|
||||
|
||||
export type MobileNativeChatLiveLine = NativeChatLiveLine & { reasoningExpanded: boolean }
|
||||
|
||||
function useScopedOpenKeys(scopeKey: string): [ReadonlySet<string>, (key: string) => void] {
|
||||
@@ -120,13 +99,14 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
liveLine: MobileNativeChatLiveLine | null
|
||||
onToggleReasoning: (key: string) => void
|
||||
} {
|
||||
const { rows, turnKeys, liveTurnKey, unresolvedRowIds } = useMemo(() => {
|
||||
const { rows, turnKeys, liveTurnKey, unresolvedRowIds, partialTurnKey } = useMemo(() => {
|
||||
if (!enabled) {
|
||||
return {
|
||||
rows: messages,
|
||||
turnKeys: NO_TURN_KEYS,
|
||||
liveTurnKey: undefined,
|
||||
unresolvedRowIds: undefined
|
||||
unresolvedRowIds: undefined,
|
||||
partialTurnKey: undefined
|
||||
}
|
||||
}
|
||||
const membership = nativeChatTurnMembership(messages, turnJournal)
|
||||
@@ -134,9 +114,11 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
rows: nativeChatRowsInDrawOrder(messages, membership.drawOrder),
|
||||
turnKeys: nativeChatRowsInDrawOrder(membership.turnKeys, membership.drawOrder),
|
||||
liveTurnKey: membership.liveTurnKey,
|
||||
partialTurnKey: membership.partialTurnKey,
|
||||
unresolvedRowIds: membership.unresolvedRowIds
|
||||
}
|
||||
}, [enabled, messages, turnJournal])
|
||||
const summaries = useMobileNativeChatTurnDiffs(rows, enabled, turnKeys, partialTurnKey)
|
||||
const waiting = useMemo(() => {
|
||||
const ids = enabled
|
||||
? nativeChatMessagesWaitingBehindLiveTurn(
|
||||
@@ -236,6 +218,7 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
: (completedByTurn[turnKey] ?? null)
|
||||
: null
|
||||
return {
|
||||
turnDiff: summaries.get(message.id),
|
||||
turnStatus,
|
||||
...(bar?.above === true && turnStatus !== null ? { turnStatusAbove: true } : {}),
|
||||
turnExpanded: turnKey ? expandedTurnIds.has(turnKey) : false,
|
||||
@@ -269,6 +252,7 @@ export function useMobileNativeChatTurnDisclosure({
|
||||
},
|
||||
[
|
||||
turnKeys,
|
||||
summaries,
|
||||
waiting,
|
||||
bars,
|
||||
enabled,
|
||||
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem
|
||||
} from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import { readRecord } from '../../app-server/codex-item-field-readers'
|
||||
import { codexItemRunState } from '../../structured/items/codex-item-run-state'
|
||||
import type { CodexThreadItem } from '../../structured/items/codex-thread-item-identity'
|
||||
|
||||
export function preserveSavedHistoryPayload(
|
||||
@@ -10,8 +12,35 @@ export function preserveSavedHistoryPayload(
|
||||
saved: AgentJournalRenderItem | null
|
||||
): AgentJournalItemBody {
|
||||
const old = saved?.body
|
||||
if (old?.kind === 'diff' && item.type === 'fileChange' && item.changes === undefined) {
|
||||
translated = old
|
||||
if (item.type === 'fileChange' && item.changes === undefined) {
|
||||
const fields = readRecord(old?.kind === 'tool-call' ? old.input : null)
|
||||
const patch =
|
||||
old?.kind === 'diff'
|
||||
? old
|
||||
: old?.kind === 'tool-call' &&
|
||||
old.name === 'Diff' &&
|
||||
typeof fields.path === 'string' &&
|
||||
Object.keys(fields).length === 1 &&
|
||||
old.output
|
||||
? { path: fields.path, patch: old.output }
|
||||
: null
|
||||
if (patch && old) {
|
||||
// Retained bytes never override authoritative application status.
|
||||
const state = codexItemRunState(item)
|
||||
translated =
|
||||
item.status == null
|
||||
? old
|
||||
: state === 'completed'
|
||||
? { kind: 'diff', path: patch.path, patch: patch.patch }
|
||||
: {
|
||||
kind: 'tool-call',
|
||||
name: 'Diff',
|
||||
callId: old?.kind === 'tool-call' ? old.callId : item.id,
|
||||
input: { path: patch.path },
|
||||
output: patch.patch,
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
old?.kind === 'message' &&
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../../shared/agent-session/journal/agent-session-journal-item-key'
|
||||
import { isAdmissibleAgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-schemas'
|
||||
import type { AgentJournalRenderItem } from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import {
|
||||
EMPTY_STRUCTURED_AGENT_SESSION,
|
||||
reduceStructuredAgentSession,
|
||||
type StructuredAgentSessionState
|
||||
} from '../../../../shared/agent-session/structured-agent-session-reducer'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import {
|
||||
nativeChatTurnDiffEditFile,
|
||||
nativeChatTurnDiffs
|
||||
} from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import type { StructuredAgentSessionEventSink } from '../../../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { agentJournalItemForWire } from '../../../native-chat/agent-session-wire/agent-session-item-wire-form'
|
||||
import {
|
||||
createCodexHistoryTranslator,
|
||||
createCodexJournalTranslator
|
||||
} from '../journal/codex-structured-journal-translation'
|
||||
import type { CodexStructuredSessionEvent } from '../session-lifecycle/codex-structured-session-state'
|
||||
|
||||
const disposers: (() => void)[] = []
|
||||
afterEach(() => disposers.splice(0).forEach((dispose) => dispose()))
|
||||
const patch = '@@ -1 +1 @@\n-old\n+new'
|
||||
// Protocol shape also exercised by codex-structured-reasoning-lifecycle.test.ts; no real CLI run is claimed.
|
||||
const change = (status?: string) => ({
|
||||
type: 'fileChange',
|
||||
id: 'patch',
|
||||
...(status ? { status } : {}),
|
||||
changes: [{ path: 'src/app.ts', kind: { type: 'update' }, diff: patch }]
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
let sequence = 0
|
||||
let state: StructuredAgentSessionState = { ...EMPTY_STRUCTURED_AGENT_SESSION, epoch: 'epoch' }
|
||||
const apply = (items: AgentJournalRenderItem[]) => {
|
||||
state = reduceStructuredAgentSession(state, {
|
||||
type: 'event',
|
||||
event: {
|
||||
type: 'batch',
|
||||
sessionId: 'session',
|
||||
batch: {
|
||||
cursor: { epoch: 'epoch', sequence: ++sequence },
|
||||
items,
|
||||
removedItemIds: [],
|
||||
submissions: []
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const sink: StructuredAgentSessionEventSink = {
|
||||
appendItem: (identity, body, options) => {
|
||||
const itemId = agentJournalItemKey(identity)
|
||||
const previous = state.items.find((item) => item.itemId === itemId)
|
||||
apply([
|
||||
agentJournalItemForWire({
|
||||
itemId,
|
||||
body,
|
||||
revision: (previous?.revision ?? 0) + 1,
|
||||
sequence: previous?.sequence ?? sequence + 1,
|
||||
observedAt: options.observedAt ?? 1
|
||||
}).item
|
||||
])
|
||||
},
|
||||
appendTombstone: () => {},
|
||||
publish: () => {}
|
||||
}
|
||||
const live = createCodexJournalTranslator({ sink, schedule: () => () => {} })
|
||||
const history = createCodexHistoryTranslator({ sink })
|
||||
disposers.push(live.dispose, history.dispose)
|
||||
const send = (method: string, params: unknown) =>
|
||||
live.handle({
|
||||
type: 'notification',
|
||||
sessionId: 'session',
|
||||
threadId: 'thread',
|
||||
method,
|
||||
params
|
||||
} satisfies CodexStructuredSessionEvent)
|
||||
const outcome = () => {
|
||||
const messages = projectStructuredItemsToNativeChat(state.items)
|
||||
return nativeChatTurnDiffs(
|
||||
messages,
|
||||
messages.map(() => 'turn')
|
||||
).get('turn')
|
||||
}
|
||||
return {
|
||||
live,
|
||||
history,
|
||||
send,
|
||||
outcome,
|
||||
replay: () => {
|
||||
const items = state.items.map((item) => {
|
||||
const restored: unknown = JSON.parse(JSON.stringify(item))
|
||||
if (!isAdmissibleAgentJournalRenderItem(restored)) {
|
||||
throw new Error('Journal item failed replay validation')
|
||||
}
|
||||
return restored
|
||||
})
|
||||
state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
||||
type: 'history-page',
|
||||
page: {
|
||||
sessionId: 'session',
|
||||
epoch: 'epoch',
|
||||
direction: 'tail',
|
||||
items,
|
||||
submissions: [],
|
||||
removedItemIds: [],
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
liveCursor: { epoch: 'epoch', sequence },
|
||||
window: {
|
||||
oldest: null,
|
||||
newest: null,
|
||||
nextCursor: { epoch: 'epoch', sequence: 0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('shows one landed patch only after completion, including stream updates and replay revisions', () => {
|
||||
const f = fixture()
|
||||
f.send('item/started', { item: change('inProgress') })
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.send('item/fileChange/outputDelta', {
|
||||
itemId: 'patch',
|
||||
delta: '@@ -1 +1 @@\n-stdout\n+not-a-patch'
|
||||
})
|
||||
f.send('item/fileChange/patchUpdated', { itemId: 'patch', changes: change().changes })
|
||||
f.live.flush()
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.send('item/completed', { item: change('completed') })
|
||||
f.live.flush()
|
||||
expect(f.outcome()?.files.map((file) => file.path)).toEqual(['src/app.ts'])
|
||||
expect(f.outcome()).toMatchObject({ added: 1, removed: 1 })
|
||||
expect(f.outcome()?.files[0]?.edits).toHaveLength(1)
|
||||
expect(nativeChatTurnDiffEditFile(f.outcome()!.files[0]!.edits[0]!)).toMatchObject({
|
||||
path: 'src/app.ts',
|
||||
added: 1,
|
||||
removed: 1
|
||||
})
|
||||
f.replay()
|
||||
f.send('item/completed', { item: change('completed') })
|
||||
expect(f.outcome()?.files[0]?.edits).toHaveLength(1)
|
||||
expect(f.outcome()).toMatchObject({ added: 1, removed: 1 })
|
||||
})
|
||||
|
||||
it.each(['failed', 'declined', 'inProgress', undefined])(
|
||||
'never promotes %s changes via a completion wrapper or history import',
|
||||
(status) => {
|
||||
const f = fixture()
|
||||
f.send('item/started', { item: change('inProgress') })
|
||||
f.send('item/completed', { item: change(status) })
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.history.restoreThread('thread', {
|
||||
turns: [{ id: 'turn', status: 'completed', items: [change(status)] }]
|
||||
})
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.replay()
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps an orphan patch update unconfirmed and settles buffered stdout without a landed file', () => {
|
||||
const f = fixture()
|
||||
f.send('item/fileChange/patchUpdated', { itemId: 'patch', changes: change().changes })
|
||||
f.live.flush()
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.send('item/started', { item: change('inProgress') })
|
||||
f.send('item/fileChange/outputDelta', { itemId: 'patch', delta: patch })
|
||||
f.live.handle({ type: 'ended', sessionId: 'session', reason: 'app-server exited' })
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
f.replay()
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
})
|
||||
@@ -92,6 +92,20 @@ function commandItem(item: CodexThreadItem): CodexJournalItem {
|
||||
}
|
||||
}
|
||||
|
||||
function fileChangeToolCall(item: CodexThreadItem, output?: string): CodexJournalItem {
|
||||
return {
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'apply_patch',
|
||||
callId: item.id,
|
||||
input: { changes: item.changes ?? null },
|
||||
state: codexItemRunState(item),
|
||||
...(output === undefined ? {} : { output: journalPayload(output) })
|
||||
},
|
||||
handled: true
|
||||
}
|
||||
}
|
||||
|
||||
function fileChangeItem(item: CodexThreadItem): CodexJournalItem {
|
||||
const changes = Array.isArray(item.changes)
|
||||
? item.changes.flatMap((change) => {
|
||||
@@ -101,17 +115,8 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem {
|
||||
return path && diff ? [{ path, diff }] : []
|
||||
})
|
||||
: []
|
||||
if (changes.length === 0) {
|
||||
return {
|
||||
body: {
|
||||
kind: 'tool-call',
|
||||
name: 'apply_patch',
|
||||
callId: item.id,
|
||||
input: { changes: item.changes ?? null },
|
||||
state: codexItemRunState(item)
|
||||
},
|
||||
handled: true
|
||||
}
|
||||
if (changes.length === 0 || codexItemRunState(item) !== 'completed') {
|
||||
return fileChangeToolCall(item)
|
||||
}
|
||||
const patch = changes.map((change) => change.diff).join('\n')
|
||||
return {
|
||||
@@ -267,13 +272,7 @@ export function codexStreamingJournalItem(item: CodexThreadItem, text: string):
|
||||
return commandItem({ ...item, aggregatedOutput: text })
|
||||
}
|
||||
if (item.type === 'fileChange') {
|
||||
const path = Array.isArray(item.changes)
|
||||
? readString(readRecord(item.changes[0]), 'path')
|
||||
: null
|
||||
return {
|
||||
body: { kind: 'diff', path: path ?? 'pending patch', patch: journalPayload(text) },
|
||||
handled: true
|
||||
}
|
||||
return fileChangeToolCall(item, text)
|
||||
}
|
||||
if (item.type === 'plan') {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import {
|
||||
nativeChatTurnDiffEditFile,
|
||||
nativeChatTurnDiffs
|
||||
} from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import { chat, turn } from './codex-thread-catch-up-contract-test-fixture'
|
||||
|
||||
const patch = '@@ -1 +1 @@\n-old\n+new'
|
||||
|
||||
async function savedChange() {
|
||||
const c = await chat({ now: 1_000 })
|
||||
const source = (status?: string, changes?: unknown[]) => ({
|
||||
...turn(1),
|
||||
items: [
|
||||
{
|
||||
type: 'fileChange',
|
||||
id: 'patch',
|
||||
...(status === undefined ? {} : { status }),
|
||||
...(changes === undefined ? {} : { changes })
|
||||
}
|
||||
]
|
||||
})
|
||||
await c.open([
|
||||
source('completed', [{ path: 'src/app.ts', kind: { type: 'update' }, diff: patch }])
|
||||
])
|
||||
const original = c.journal.snapshot().items.find((item) => item.body.kind === 'diff')!
|
||||
expect(original.body.kind).toBe('diff')
|
||||
const current = () => c.journal.snapshot().items.find((item) => item.itemId === original.itemId)!
|
||||
const outcome = () => {
|
||||
const messages = projectStructuredItemsToNativeChat(c.journal.snapshot().items)
|
||||
return nativeChatTurnDiffs(
|
||||
messages,
|
||||
messages.map(() => 'turn')
|
||||
).get('turn')
|
||||
}
|
||||
return { c, source, original, current, outcome }
|
||||
}
|
||||
|
||||
it('uses declined history status without losing the patch or resurrecting it on sparse enrichment', async () => {
|
||||
const f = await savedChange()
|
||||
await f.c.open([], { returnedTurns: [f.source('declined')], paginate: false })
|
||||
expect(f.current().body).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
name: 'Diff',
|
||||
input: { path: 'src/app.ts' },
|
||||
state: 'failed'
|
||||
})
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
const failed = f.current().body
|
||||
expect(failed.kind === 'tool-call' && failed.output).toEqual(
|
||||
f.original.body.kind === 'diff' && f.original.body.patch
|
||||
)
|
||||
await f.c.open([], { returnedTurns: [f.source()], paginate: false })
|
||||
expect(f.current().body).toEqual(failed)
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['completed', undefined])(
|
||||
'retains the saved successful patch for sparse %s history',
|
||||
async (status) => {
|
||||
const f = await savedChange()
|
||||
await f.c.open([], { returnedTurns: [f.source(status)], paginate: false })
|
||||
expect(f.current().body).toEqual(f.original.body)
|
||||
expect(f.outcome()).toMatchObject({ added: 1, removed: 1 })
|
||||
expect(f.outcome()?.files.map((file) => file.path)).toEqual(['src/app.ts'])
|
||||
expect(f.outcome()?.files[0]?.edits).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('retains a sparse pending patch until a new import confirms its application', async () => {
|
||||
const f = await savedChange()
|
||||
await f.c.open([], { returnedTurns: [f.source('inProgress')], paginate: false })
|
||||
expect(f.current().body).toMatchObject({ kind: 'tool-call', name: 'Diff', state: 'running' })
|
||||
expect(f.outcome()).toBeUndefined()
|
||||
await f.c.open([], { returnedTurns: [f.source('completed')], paginate: false })
|
||||
expect(f.current().body).toEqual(f.original.body)
|
||||
const outcome = f.outcome()!
|
||||
expect(outcome).toMatchObject({ added: 1, removed: 1 })
|
||||
expect(outcome.files.map((file) => file.path)).toEqual(['src/app.ts'])
|
||||
expect(outcome.files[0]?.edits).toHaveLength(1)
|
||||
expect(nativeChatTurnDiffEditFile(outcome.files[0]!.edits[0]!)).toMatchObject({
|
||||
path: 'src/app.ts',
|
||||
lines: [
|
||||
{ kind: 'del', text: 'old' },
|
||||
{ kind: 'add', text: 'new' }
|
||||
]
|
||||
})
|
||||
})
|
||||
@@ -246,9 +246,10 @@ describe('a Codex reasoning row', () => {
|
||||
for (let index = 2; index <= MAX_CODEX_ACTIVE_ITEMS + 1; index += 1) {
|
||||
items.handle({ threadId: THREAD_ID, method: 'item/started', params: reasoning(`r-${index}`) })
|
||||
}
|
||||
expect(rows.get('orca:codex-item%3Athread-abc%3Apatch-1')).toEqual({
|
||||
kind: 'status',
|
||||
text: 'File changes were interrupted before completion.'
|
||||
expect(rows.get('orca:codex-item%3Athread-abc%3Apatch-1')).toMatchObject({
|
||||
kind: 'tool-call',
|
||||
name: 'apply_patch',
|
||||
state: 'failed'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+7
-21
@@ -563,8 +563,6 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
}
|
||||
|
||||
{
|
||||
const scrollTo = vi.fn()
|
||||
|
||||
function item(
|
||||
itemId: string,
|
||||
body: AgentJournalItemBody,
|
||||
@@ -759,25 +757,13 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
expect(screen.getAllByText('Awaiting user input:')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reveals and scrolls to a folded diff card from a collapsed completed turn', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
|
||||
it('opens a recorded patch while completed work stays folded', () => {
|
||||
render(view([user, prose, diff()]))
|
||||
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
|
||||
const header = screen.getByRole('button', { name: /1 changed file/ })
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(header)
|
||||
expect(screen.queryByText('after')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
expect(screen.getByText('Edited')).toBeInTheDocument()
|
||||
expect(screen.getByText('after')).toBeInTheDocument()
|
||||
expect(screen.getByText('before')).toBeInTheDocument()
|
||||
expect(scrollTo).toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Edited 1 file/ }))
|
||||
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
|
||||
fireEvent.click(header)
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(header)
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
expect(scrollTo.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
expect(screen.getByRole('button', { name: /Edited 1 file/, expanded: false })).toBeVisible()
|
||||
})
|
||||
|
||||
it('updates journal revisions and replaces flat approval text with a passive receipt', () => {
|
||||
@@ -817,7 +803,7 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
}
|
||||
}
|
||||
rerender(view([user, prose, diff('@@ -0,0 +1,2 @@\n+first\n+second'), resolved]))
|
||||
expect(screen.getByRole('button', { name: /1 changed file \+2/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /src\/a.ts \+2/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('Run tests?')).toBeInTheDocument()
|
||||
expect(screen.getByText('Allow once')).toBeInTheDocument()
|
||||
expect(screen.getByText('Answered on another device')).toBeInTheDocument()
|
||||
@@ -825,7 +811,7 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
expect(screen.queryByText('resolved')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps rollups turn-local and leaves legacy message lists unchanged', () => {
|
||||
it('keeps changed files turn-local in journal and legacy transcripts', () => {
|
||||
const secondUser = item(
|
||||
'user-two',
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Again' }] },
|
||||
@@ -834,9 +820,9 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
const secondDiff = { ...diff(), itemId: 'second-diff', sequence: 6, observedAt: 6000 }
|
||||
const items = [user, prose, diff(), secondUser, secondDiff]
|
||||
const { rerender } = render(view(items))
|
||||
expect(screen.getAllByRole('button', { name: /1 changed file/ })).toHaveLength(2)
|
||||
expect(screen.getAllByRole('button', { name: /src\/a.ts/ })).toHaveLength(2)
|
||||
rerender(view(items, false))
|
||||
expect(screen.queryByRole('button', { name: /changed file/ })).toBeNull()
|
||||
expect(screen.getAllByRole('button', { name: /src\/a.ts/ })).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
-111
@@ -26,12 +26,7 @@ afterEach(() => {
|
||||
expect(unusedChatLeaves.inlineVisual).not.toHaveBeenCalled()
|
||||
expect(unusedChatLeaves.openSender).not.toHaveBeenCalled()
|
||||
})
|
||||
import type {
|
||||
AgentJournalItemBody,
|
||||
AgentJournalRenderItem
|
||||
} from '../../../../shared/agent-session/journal/agent-session-journal-types'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import { transcriptRowOffset, transcriptWindowTop } from './native-chat-window-test-layout'
|
||||
import type { NativeChatRailOutlineEntry } from './native-chat-message-rail-items'
|
||||
@@ -414,110 +409,4 @@ describe('jumping from the rail while following the end', () => {
|
||||
expect(screen.queryByText('prompt-5')).toBeNull()
|
||||
expect(distanceFromBottom()).toBe(0)
|
||||
})
|
||||
|
||||
describe('revealing a diff while a rail jump pages', () => {
|
||||
function journalItem(itemId: string, body: AgentJournalItemBody, sequence: number) {
|
||||
return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 }
|
||||
}
|
||||
function prompt(itemId: string, text: string, sequence: number) {
|
||||
return journalItem(
|
||||
itemId,
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text }] },
|
||||
sequence
|
||||
)
|
||||
}
|
||||
|
||||
const patch = '@@ -1 +1 @@\n-before\n+after'
|
||||
const OLDER = prompt('older', 'Oldest prompt', 1)
|
||||
const LOADED: AgentJournalRenderItem[] = [
|
||||
prompt('user', 'Edit it', 2),
|
||||
journalItem(
|
||||
'diff',
|
||||
{
|
||||
kind: 'diff',
|
||||
path: 'src/a.ts',
|
||||
patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length }
|
||||
},
|
||||
3
|
||||
),
|
||||
...Array.from({ length: 200 }, (_, index) =>
|
||||
journalItem(
|
||||
`tail-${index}`,
|
||||
{
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: `marker-${index}` }]
|
||||
},
|
||||
index + 4
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
function DiffTranscript({ holdPage }: { holdPage: () => Promise<void> }): React.JSX.Element {
|
||||
const [loadedOlder, setLoadedOlder] = useState(false)
|
||||
const items = useMemo(() => (loadedOlder ? [OLDER, ...LOADED] : LOADED), [loadedOlder])
|
||||
const loadEarlier = useCallback(async (): Promise<NativeChatOlderPageResult> => {
|
||||
await holdPage()
|
||||
setLoadedOlder(true)
|
||||
return 'applied'
|
||||
}, [holdPage])
|
||||
return (
|
||||
<NativeChatMessageList
|
||||
session={{
|
||||
...session(projectStructuredItemsToNativeChat(items)),
|
||||
hasMore: !loadedOlder,
|
||||
loadEarlier
|
||||
}}
|
||||
journalItems={items}
|
||||
railOutline={
|
||||
loadedOlder ? [] : [{ id: 'older', text: 'Oldest prompt', hasImages: false }]
|
||||
}
|
||||
isWorking={false}
|
||||
expandSignal={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function expectDiffInView(): void {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/, expanded: true })
|
||||
).toBeInTheDocument()
|
||||
const row = screen.getByText('Edited').closest<HTMLElement>('[data-index]')
|
||||
if (!row) {
|
||||
throw new Error('revealed diff has no mounted row')
|
||||
}
|
||||
const top = layout.aboveTranscriptPx + row.offsetTop - scroller().scrollTop
|
||||
expect(row.offsetHeight).toBeGreaterThan(0)
|
||||
expect(top).toBeGreaterThanOrEqual(-2)
|
||||
expect(top).toBeLessThan(scroller().clientHeight - 2)
|
||||
}
|
||||
|
||||
it('keeps the diff in view when its page lands', async () => {
|
||||
const pages = holdFirstPage()
|
||||
render(<DiffTranscript holdPage={pages.holdPage} />)
|
||||
await settle(10)
|
||||
expect(distanceFromBottom()).toBe(0)
|
||||
expect(screen.queryByText('Edited')).toBeNull()
|
||||
|
||||
await pickUnloadedWhilePaging('Oldest prompt')
|
||||
expect(pages.asked()).toBe(1)
|
||||
expect(screen.getByRole('button', { name: 'Oldest prompt' })).toHaveAttribute(
|
||||
'aria-busy',
|
||||
'true'
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
expect(screen.getByRole('button', { name: 'Oldest prompt' })).not.toHaveAttribute('aria-busy')
|
||||
await settle(40)
|
||||
expectDiffInView()
|
||||
|
||||
pages.release()
|
||||
await settle(60)
|
||||
|
||||
expect(pages.asked()).toBe(1)
|
||||
expect(screen.getByRole('button', { name: 'Oldest prompt' })).not.toHaveAttribute('aria-busy')
|
||||
expectDiffInView()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -211,14 +211,11 @@ describe('native chat transcript re-render cost during a streaming turn', () =>
|
||||
)
|
||||
}
|
||||
expect(patchCalls).toEqual({ summary: 1, detailed: 0 })
|
||||
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
|
||||
expect(patchCalls.detailed).toBe(0)
|
||||
expect(screen.queryByText('Totals from recorded edits in this turn.')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show diff' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^src\/a.ts/ }))
|
||||
expect(patchCalls).toEqual({ summary: 1, detailed: 1 })
|
||||
expect(screen.getByText('new')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide diff' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^src\/a.ts/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^src\/a.ts/ }))
|
||||
expect(patchCalls).toEqual({ summary: 1, detailed: 1 })
|
||||
expect(screen.getByText('new')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
+8
-125
@@ -54,7 +54,6 @@ import {
|
||||
list,
|
||||
marker,
|
||||
scrollTranscript,
|
||||
session as materialRailSession,
|
||||
stubLayout as materialRailStubLayout,
|
||||
windowState
|
||||
} from './NativeChatMessageList.windowing-test-support'
|
||||
@@ -247,17 +246,16 @@ import {
|
||||
expect(screen.getAllByText(/^lane [abc]$/)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("still counts the subagent's edit in the turn, and reveals it inside its section", () => {
|
||||
it("shows the subagent's patch without opening its diagnostic section", () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(() => {})
|
||||
renderList()
|
||||
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
|
||||
expect(screen.getByText('Edited')).toBeInTheDocument()
|
||||
expect(screen.getByText('The PR is CLEAN.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /explore the lane/, expanded: true })).toBeVisible()
|
||||
expect(screen.queryByText('The PR is CLEAN.')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: false })).toBeVisible()
|
||||
})
|
||||
|
||||
// Journal turn records own subagent edits, even when a newer send is queued or the opener is unloaded.
|
||||
@@ -304,10 +302,10 @@ import {
|
||||
expandSignal={false}
|
||||
/>
|
||||
)
|
||||
const rollups = screen.getAllByRole('button', { name: /changed file/ })
|
||||
expect(rollups.map((rollup) => rollup.textContent)).toEqual([
|
||||
expect.stringMatching(/1 changed file/),
|
||||
expect.stringMatching(/1 changed file/)
|
||||
const rollups = screen.getAllByRole('button', { name: /src\/[ab].ts/ })
|
||||
expect(rollups.map((rollup) => rollup.getAttribute('aria-label'))).toEqual([
|
||||
expect.stringMatching(/src\/a.ts/),
|
||||
expect.stringMatching(/src\/b.ts/)
|
||||
])
|
||||
const inOrder = [
|
||||
screen.getByText('Edited a.'),
|
||||
@@ -327,11 +325,10 @@ import {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Ran 1 subagent/ }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
|
||||
expect(screen.getByText('Edited')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: true })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: /Ran 1 subagent/, expanded: false })).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps a roster list open or closed past its row unmounting, and hides its sections while closed', () => {
|
||||
@@ -425,122 +422,8 @@ import {
|
||||
}
|
||||
|
||||
{
|
||||
const session = materialRailSession
|
||||
const stubLayout = materialRailStubLayout
|
||||
|
||||
describe('revealing a diff from a turn rollup', () => {
|
||||
afterEach(cleanup)
|
||||
let restoreLayout = (): void => {}
|
||||
beforeEach(() => {
|
||||
restoreLayout = stubLayout({ scrollGeometry: true, offsetChain: true })
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
|
||||
this: HTMLElement
|
||||
) {
|
||||
const row = this.closest<HTMLElement>('[data-index]')
|
||||
const scroller = this.closest<HTMLElement>('[data-native-chat-scroll]')
|
||||
return new DOMRect(
|
||||
0,
|
||||
row ? row.offsetTop - (scroller?.scrollTop ?? 0) : 0,
|
||||
100,
|
||||
this.offsetHeight
|
||||
)
|
||||
})
|
||||
})
|
||||
afterEach(() => {
|
||||
restoreLayout()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function journalItem(itemId: string, body: AgentJournalItemBody, sequence: number) {
|
||||
return { itemId, body, sequence, observedAt: sequence * 1000, revision: 1 }
|
||||
}
|
||||
|
||||
const patch = '@@ -1 +1 @@\n-before\n+after'
|
||||
const items: AgentJournalRenderItem[] = [
|
||||
journalItem(
|
||||
'user',
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Edit it' }] },
|
||||
1
|
||||
),
|
||||
journalItem(
|
||||
'diff',
|
||||
{
|
||||
kind: 'diff',
|
||||
path: 'src/a.ts',
|
||||
patch: { head: patch, truncated: false, digest: 'fixture', byteLength: patch.length }
|
||||
},
|
||||
2
|
||||
),
|
||||
...Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) =>
|
||||
journalItem(
|
||||
`tail-${index}`,
|
||||
{
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: `marker-${index}` }]
|
||||
},
|
||||
index + 3
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
it('lets a rail jump supersede a previously revealed diff', () => {
|
||||
const withPrompts = [
|
||||
...items.slice(0, 2),
|
||||
journalItem(
|
||||
'user-2',
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Second prompt' }] },
|
||||
3
|
||||
),
|
||||
journalItem(
|
||||
'user-3',
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Third prompt' }] },
|
||||
4
|
||||
),
|
||||
...items.slice(2)
|
||||
].map((item, index) => ({ ...item, sequence: index + 1 }))
|
||||
const scrollTo = vi.fn()
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
|
||||
const transcript = (): React.JSX.Element => (
|
||||
<NativeChatMessageList
|
||||
session={session(projectStructuredItemsToNativeChat(withPrompts))}
|
||||
journalItems={withPrompts}
|
||||
isWorking={false}
|
||||
expandSignal={false}
|
||||
/>
|
||||
)
|
||||
const { container, rerender } = render(transcript())
|
||||
const selectedTop =
|
||||
screen.getByText('Second prompt').closest<HTMLElement>('[data-index]')?.offsetTop ?? 0
|
||||
expect(selectedTop).toBeGreaterThan(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
scrollTranscript(container, 6000)
|
||||
expect(screen.getByText('Edited')).toBeInTheDocument()
|
||||
scrollTo.mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Second prompt' }))
|
||||
expect(scrollTo.mock.calls).toEqual([
|
||||
[{ top: 6000, behavior: 'auto' }],
|
||||
[{ top: selectedTop, behavior: 'auto' }]
|
||||
])
|
||||
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
|
||||
scrollTo.mockClear()
|
||||
rerender(transcript())
|
||||
rerender(transcript())
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
|
||||
scrollTranscript(container, 0)
|
||||
scrollTo.mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
const diffTop =
|
||||
screen.getByText('Edited').closest<HTMLElement>('[data-index]')?.offsetTop ?? 0
|
||||
expect(Number.isFinite(diffTop)).toBe(true)
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: diffTop, behavior: 'auto' })
|
||||
expect(scrollTo.mock.calls.some(([options]) => options?.behavior === 'smooth')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Rail jumps borrow the diff-reveal pin; release it after scrolling so later renders cannot repeat or outrank navigation.
|
||||
describe('jumping to a message from the rail', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -61,7 +61,10 @@ import type {
|
||||
import type { AgentSessionLatestTurn } from '../../../../shared/agent-session/agent-session-wire'
|
||||
import { isStructuredAgentSessionThinking } from '../../../../shared/agent-session/view/structured-agent-session-live-turn'
|
||||
import type { NativeChatSettledTurns } from '../../../../shared/native-chat/transcript/native-chat-turn-status'
|
||||
import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs'
|
||||
import type {
|
||||
NativeChatDiffReveal,
|
||||
NativeChatDiffTarget
|
||||
} from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import { useNativeChatTurnDiffs } from './use-native-chat-turn-diffs'
|
||||
import { transcriptTailRow } from './native-chat-transcript-tail-row'
|
||||
import type { NativeChatAwaitingInput } from '../../../../shared/native-chat/native-chat-terminal-turn'
|
||||
@@ -171,11 +174,7 @@ export function NativeChatMessageList({
|
||||
latestTurn
|
||||
)
|
||||
const { messages: rows, turnKeys, liveTurnKey } = turnRows
|
||||
const turnDiffs = useNativeChatTurnDiffs(
|
||||
journalItems ? turnRows : null,
|
||||
subagentRowsInOrder,
|
||||
subagentSections.pathOf
|
||||
)
|
||||
const turnDiffs = useNativeChatTurnDiffs(turnRows, subagentRowsInOrder, subagentSections.pathOf)
|
||||
// Only real tail reasoning counts as thinking; an in-flight request with no output does not.
|
||||
const thinking = useMemo(
|
||||
() => isStructuredAgentSessionThinking({ items: journalItems ?? [], latestTurn }),
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('revealing a diff from a turn rollup', () => {
|
||||
)
|
||||
]
|
||||
|
||||
it('mounts the row a reveal names even when the window has left it behind', () => {
|
||||
it('opens an old edit inline at the visible turn outcome without mounting its source row', () => {
|
||||
const scrollTo = vi.fn()
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollTo').mockImplementation(scrollTo)
|
||||
const { container } = render(
|
||||
@@ -237,14 +237,13 @@ describe('revealing a diff from a turn rollup', () => {
|
||||
expect(screen.queryByRole('button', { name: /^Edited .*a\.ts(?:\s|$)/ })).toBeNull()
|
||||
const mountedBefore = windowState(container).indexes.length
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /1 changed file/ }))
|
||||
scrollTo.mockClear()
|
||||
fireEvent.click(screen.getByRole('button', { name: /src\/a.ts/ }))
|
||||
|
||||
expect(screen.getByText('Edited')).toBeInTheDocument()
|
||||
expect(screen.getByText('after')).toBeInTheDocument()
|
||||
expect(scrollTo).toHaveBeenCalled()
|
||||
// Pinned, not paged to: the window is still a window.
|
||||
expect(scrollTo).not.toHaveBeenCalled()
|
||||
// The outcome reuses the bounded transcript window.
|
||||
expect(windowState(container).indexes.length).toBeLessThanOrEqual(mountedBefore + 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import { nativeChatNoticeBlock } from '../../../../shared/native-chat/native-cha
|
||||
import { nativeChatStoppedBeforeStartLabel } from './native-chat-stopped-before-start-row'
|
||||
import { nativeChatBlocksInOwnWords } from '../../../../shared/native-chat/native-chat-stopped-before-start'
|
||||
import { ProviderFrameRow } from './NativeChatTranscriptChrome'
|
||||
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
|
||||
import type { NativeChatDiffReveal } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import type {
|
||||
NativeChatSubagentDisclosure,
|
||||
NativeChatSubagentRosterState
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type NativeChatToolCallBlock
|
||||
} from '../../../../shared/native-chat/native-chat-types'
|
||||
import { isRenderableSubagentGroup } from '../../../../shared/native-chat/transcript/native-chat-subagent-summary'
|
||||
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
|
||||
import type { NativeChatDiffReveal } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import { nativeChatToolRunSentence } from './native-chat-tool-run-label'
|
||||
import { NATIVE_CHAT_TOOL_ACTIVITY_COPY } from '../../../../shared/native-chat/tools/native-chat-tool-activity'
|
||||
import { nativeChatToolRunHeader } from '../../../../shared/native-chat/tools/native-chat-tool-run-header'
|
||||
|
||||
@@ -11,7 +11,7 @@ import { NativeChatTaskList } from './NativeChatTaskList'
|
||||
import { NativeChatToolLine } from './NativeChatToolLine'
|
||||
import { buildEditCards } from '../../../../shared/native-chat/tools/native-chat-edit-cards'
|
||||
import { buildNativeChatTaskListRows } from '../../../../shared/native-chat/input/native-chat-task-list-history'
|
||||
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
|
||||
import type { NativeChatDiffReveal } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import type { NativeChatToolRunAsides } from './NativeChatToolRun'
|
||||
|
||||
/** Mount only while expanded so collapsed runs avoid member pairing and diff work. */
|
||||
|
||||
@@ -11,7 +11,10 @@ import { NativeChatSubagentEntries } from './NativeChatSubagentRun'
|
||||
import type { NativeChatSubagentDisclosure } from './native-chat-subagent-sections'
|
||||
import type { NativeChatTaskListPredecessors } from '../../../../shared/native-chat/input/native-chat-task-list-history'
|
||||
import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots'
|
||||
import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs'
|
||||
import type {
|
||||
NativeChatDiffReveal,
|
||||
NativeChatDiffTarget
|
||||
} from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import { NativeChatRewindContext } from './native-chat-rewind-context'
|
||||
import { nativeChatRowOffersRewind } from './native-chat-rewind-eligibility'
|
||||
import { isActionableStructuredAgentSessionPrompt } from '../../../../shared/agent-session/view/structured-agent-session-live-turn'
|
||||
@@ -139,7 +142,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
|
||||
)}
|
||||
{slot.statusAbove ? null : statusRow}
|
||||
{turnDiff ? (
|
||||
<NativeChatTurnDiffRollup diff={turnDiff} onReveal={context.onRevealDiff} />
|
||||
<NativeChatTurnDiffRollup diff={turnDiff} onLinkClick={context.onLinkClick} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { defineFixture } from '../../component-fixtures/component-fixture'
|
||||
import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { nativeChatTurnDiffs } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
|
||||
|
||||
const messages: NativeChatMessage[] = [
|
||||
{
|
||||
id: 'user',
|
||||
role: 'user',
|
||||
source: 'transcript',
|
||||
timestamp: 0,
|
||||
blocks: [{ type: 'text', text: 'Update the greeting and mark the feature ready.' }]
|
||||
},
|
||||
{
|
||||
id: 'edits',
|
||||
role: 'assistant',
|
||||
source: 'transcript',
|
||||
timestamp: 1,
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'Bash',
|
||||
state: 'completed',
|
||||
input: { command: 'inspect sources' }
|
||||
},
|
||||
{ type: 'tool-result', output: 'Source files inspected.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'Edit',
|
||||
state: 'completed',
|
||||
input: {
|
||||
file_path: '/Users/alice/orca/workspaces/orca/feature-x/src/components/greeting.ts',
|
||||
old_string: 'return "Hello"',
|
||||
new_string: 'return `Hello, ${name}`'
|
||||
}
|
||||
},
|
||||
{ type: 'tool-result', output: 'The file was updated successfully.' },
|
||||
{ type: 'tool-call', name: 'Diff', input: { path: 'src/components/status.ts' } },
|
||||
{
|
||||
type: 'tool-result',
|
||||
output: '@@ -1 +1 @@\n-export const ready = false\n+export const ready = true'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'answer',
|
||||
role: 'assistant',
|
||||
source: 'transcript',
|
||||
timestamp: 2,
|
||||
blocks: [
|
||||
{ type: 'text', text: 'The greeting now includes the name, and the feature is ready.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
const diff = nativeChatTurnDiffs(
|
||||
messages,
|
||||
messages.map(() => 'turn')
|
||||
).get('turn')!
|
||||
export const settled = defineFixture({
|
||||
width: 560,
|
||||
render: () => <NativeChatTurnDiffRollup diff={diff} />
|
||||
})
|
||||
export const opened = defineFixture({
|
||||
width: 560,
|
||||
render: () => <NativeChatTurnDiffRollup diff={diff} />,
|
||||
interact: (frame) => {
|
||||
frame.querySelector<HTMLButtonElement>('button')?.click()
|
||||
}
|
||||
})
|
||||
|
||||
const session: NativeChatLiveSession = {
|
||||
messages,
|
||||
agent: 'claude',
|
||||
sessionId: 'fixture',
|
||||
status: 'ready',
|
||||
readPhase: 'ready',
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
olderHistoryGeneration: 0,
|
||||
loadEarlier: async () => 'applied'
|
||||
}
|
||||
function Transcript({ working = false }: { working?: boolean }) {
|
||||
return (
|
||||
<div className="flex h-[600px] flex-col">
|
||||
<NativeChatMessageList
|
||||
session={session}
|
||||
isWorking={working}
|
||||
expandSignal={false}
|
||||
settledTurns={working ? undefined : new Map([['user', { startedAt: 0, workedSeconds: 2 }]])}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export const completedTranscript = defineFixture({ width: 560, render: () => <Transcript /> })
|
||||
export const completedTranscriptOpened = defineFixture({
|
||||
width: 560,
|
||||
render: () => <Transcript />,
|
||||
interact: (frame) => {
|
||||
frame.querySelector<HTMLButtonElement>('button[title$="greeting.ts"]')?.click()
|
||||
}
|
||||
})
|
||||
export const liveTranscript = defineFixture({ width: 560, render: () => <Transcript working /> })
|
||||
@@ -0,0 +1,124 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
|
||||
import { nativeChatTurnDiffs } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import { NativeChatTurnDiffRollup } from './NativeChatTurnDiffRollup'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import { session, stubLayout, stubResizeObserver } from './native-chat-windowing-test-harness'
|
||||
|
||||
const messages: NativeChatMessage[] = [
|
||||
{
|
||||
id: 'u',
|
||||
role: 'user',
|
||||
source: 'transcript',
|
||||
timestamp: 1,
|
||||
blocks: [{ type: 'text', text: 'Fix the greeting' }]
|
||||
},
|
||||
{
|
||||
id: 'work',
|
||||
role: 'assistant',
|
||||
source: 'transcript',
|
||||
timestamp: 2,
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'Bash',
|
||||
input: { command: 'inspect-private-diagnostic' },
|
||||
state: 'completed'
|
||||
},
|
||||
{ type: 'tool-result', output: 'diagnostic-output' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'Edit',
|
||||
input: {
|
||||
file_path: '/repo/greeting.ts',
|
||||
old_string: 'helloOld()',
|
||||
new_string: 'helloNew()'
|
||||
},
|
||||
state: 'completed'
|
||||
},
|
||||
{ type: 'tool-result', output: 'The file /repo/greeting.ts has been updated successfully.' },
|
||||
{ type: 'tool-call', name: 'Diff', input: { path: 'status.ts' } },
|
||||
{ type: 'tool-result', output: '@@ -1 +1 @@\n-statusOld()\n+statusNew()' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'answer',
|
||||
role: 'assistant',
|
||||
source: 'transcript',
|
||||
timestamp: 3,
|
||||
blocks: [{ type: 'text', text: 'Updated the greeting.' }]
|
||||
}
|
||||
]
|
||||
let restoreLayout = (): void => {}
|
||||
let restoreResizeObserver = (): void => {}
|
||||
beforeEach(() => {
|
||||
restoreLayout = stubLayout({ scrollGeometry: true, offsetChain: true })
|
||||
restoreResizeObserver = stubResizeObserver()
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
restoreResizeObserver()
|
||||
restoreLayout()
|
||||
})
|
||||
|
||||
it('keeps landed files visible after work folds, opens a patch in one click and survives history remount', () => {
|
||||
const onLinkClick = vi.fn()
|
||||
const view = (working: boolean) => (
|
||||
<TooltipProvider>
|
||||
<NativeChatMessageList
|
||||
session={session(messages)}
|
||||
isWorking={working}
|
||||
expandSignal={false}
|
||||
onLinkClick={onLinkClick}
|
||||
settledTurns={working ? undefined : new Map([['u', { startedAt: 1, workedSeconds: 2 }]])}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
const { rerender, unmount } = render(view(true))
|
||||
expect(screen.getByRole('button', { name: /\/repo\/greeting.ts/ })).toBeVisible()
|
||||
rerender(view(false))
|
||||
expect(screen.getByRole('button', { name: /\/repo\/greeting.ts/ })).toBeVisible()
|
||||
expect(screen.queryByText('diagnostic-output')).toBeNull()
|
||||
expect(screen.queryByText('helloNew()')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: /\/repo\/greeting.ts/ }))
|
||||
expect(screen.getByText('helloOld()')).toBeVisible()
|
||||
expect(screen.getByText('helloNew()')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('link', { name: '/repo/greeting.ts' }))
|
||||
expect(onLinkClick).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('helloNew()')).toBeVisible()
|
||||
expect(screen.queryByText('statusNew()')).toBeNull()
|
||||
unmount()
|
||||
render(view(false))
|
||||
expect(screen.getByRole('button', { name: /status.ts/ })).toBeVisible()
|
||||
expect(screen.queryByText('diagnostic-output')).toBeNull()
|
||||
})
|
||||
|
||||
it('mounts a bounded file prefix and no patch bodies until a file is selected', () => {
|
||||
const many: NativeChatMessage[] = Array.from({ length: 200 }, (_, index) => ({
|
||||
id: `e${index}`,
|
||||
role: 'assistant',
|
||||
source: 'transcript',
|
||||
timestamp: index,
|
||||
blocks: [
|
||||
{ type: 'tool-call', name: 'Diff', input: { path: `file-${index}.ts` } },
|
||||
{ type: 'tool-result', output: `@@ -1 +1 @@\n-old-${index}\n+new-${index}` }
|
||||
]
|
||||
}))
|
||||
const diff = nativeChatTurnDiffs(
|
||||
many,
|
||||
many.map(() => 'u')
|
||||
).get('u')!
|
||||
const { container } = render(<NativeChatTurnDiffRollup diff={diff} />)
|
||||
expect(screen.getAllByRole('button', { name: /file-\d+.ts/ })).toHaveLength(20)
|
||||
expect(container.querySelectorAll('[data-native-chat-code-content]')).toHaveLength(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: /file-0.ts/ }))
|
||||
expect(screen.getByText('new-0')).toBeVisible()
|
||||
expect(screen.queryByText('new-1')).toBeNull()
|
||||
expect(container.querySelectorAll('[data-native-chat-code-content]')).toHaveLength(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Show 180 more/ }))
|
||||
expect(screen.getByRole('button', { name: /file-199.ts/ })).toBeVisible()
|
||||
})
|
||||
@@ -1,20 +1,26 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useId } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { DiffLineCounts } from '../right-sidebar/source-control/listing/diff-line-counts'
|
||||
import { NativeChatDiffCard } from './NativeChatDiffCard'
|
||||
import { NativeChatExpandable } from './NativeChatExpandable'
|
||||
import { useNativeChatDisclosure } from './native-chat-disclosure-store'
|
||||
import { nativeChatEditFileBaseName } from '../../../../shared/native-chat/tools/native-chat-edit-model'
|
||||
import type { CommentMarkdownLinkClickHandler } from '../sidebar/CommentMarkdown'
|
||||
import {
|
||||
nativeChatTurnDiffEditFile,
|
||||
type NativeChatDiffTarget,
|
||||
NATIVE_CHAT_TURN_DIFF_FILE_LIMIT,
|
||||
type NativeChatTurnDiff,
|
||||
type NativeChatTurnDiffFile
|
||||
} from './native-chat-turn-diffs'
|
||||
} from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
|
||||
function TurnDiffFilePatches({ file }: { file: NativeChatTurnDiffFile }): React.JSX.Element {
|
||||
function TurnDiffFilePatches({
|
||||
file,
|
||||
onLinkClick
|
||||
}: {
|
||||
file: NativeChatTurnDiffFile
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="pl-2">
|
||||
{file.edits.map((edit) => {
|
||||
@@ -23,6 +29,7 @@ function TurnDiffFilePatches({ file }: { file: NativeChatTurnDiffFile }): React.
|
||||
<NativeChatDiffCard
|
||||
key={`${edit.target.messageId}:${edit.target.editKey}:${edit.fileIndex}`}
|
||||
file={patch}
|
||||
onLinkClick={onLinkClick}
|
||||
initialOpen
|
||||
disclosureKey={`turn-diff-card:${edit.target.messageId}:${edit.target.editKey}:${edit.fileIndex}`}
|
||||
/>
|
||||
@@ -34,46 +41,34 @@ function TurnDiffFilePatches({ file }: { file: NativeChatTurnDiffFile }): React.
|
||||
|
||||
function TurnDiffFileRow({
|
||||
file,
|
||||
onReveal
|
||||
onLinkClick
|
||||
}: {
|
||||
file: NativeChatTurnDiffFile
|
||||
onReveal: (target: NativeChatDiffTarget) => void
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
}): React.JSX.Element {
|
||||
const pathId = useId()
|
||||
const name = nativeChatEditFileBaseName(file.path)
|
||||
const disclosureKey = `turn-diff:${file.edits[0]?.target.messageId}:${file.path}`
|
||||
const { open, setOpen } = useNativeChatDisclosure(disclosureKey, false)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex min-w-0 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="min-w-0 flex-1 justify-start"
|
||||
onClick={() => onReveal(file.target)}
|
||||
>
|
||||
<span id={pathId} className="min-w-0 truncate font-mono" title={file.path}>
|
||||
{file.path}
|
||||
</span>
|
||||
<DiffLineCounts added={file.added} removed={file.removed} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={
|
||||
open
|
||||
? translate('components.native-chat.turnDiff.hideDiff', 'Hide diff')
|
||||
: translate('components.native-chat.turnDiff.showDiff', 'Show diff')
|
||||
}
|
||||
aria-describedby={pathId}
|
||||
title={file.path}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<ChevronRight aria-hidden className={open ? 'size-3.5 rotate-90' : 'size-3.5'} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="w-full min-w-0 justify-start"
|
||||
aria-expanded={open}
|
||||
aria-label={`${file.path} +${file.added} -${file.removed}`}
|
||||
title={file.path}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<ChevronRight aria-hidden className={open ? 'size-3.5 rotate-90' : 'size-3.5'} />
|
||||
<span className="max-w-2/3 shrink-0 truncate font-mono text-chat-foreground">{name}</span>
|
||||
<span aria-hidden className="min-w-0 flex-1 truncate text-right font-mono">
|
||||
{file.path.slice(0, -name.length)}
|
||||
</span>
|
||||
<DiffLineCounts added={file.added} removed={file.removed} />
|
||||
</Button>
|
||||
<NativeChatExpandable open={open}>
|
||||
<TurnDiffFilePatches file={file} />
|
||||
<TurnDiffFilePatches file={file} onLinkClick={onLinkClick} />
|
||||
</NativeChatExpandable>
|
||||
</div>
|
||||
)
|
||||
@@ -81,36 +76,43 @@ function TurnDiffFileRow({
|
||||
|
||||
export function NativeChatTurnDiffRollup({
|
||||
diff,
|
||||
onReveal
|
||||
onLinkClick
|
||||
}: {
|
||||
diff: NativeChatTurnDiff
|
||||
onReveal: (target: NativeChatDiffTarget) => void
|
||||
onLinkClick?: CommentMarkdownLinkClickHandler
|
||||
}): React.JSX.Element {
|
||||
const { open: showAll, setOpen: setShowAll } = useNativeChatDisclosure(
|
||||
`turn-diff-files:${diff.files[0]?.edits[0]?.target.messageId}`,
|
||||
false
|
||||
)
|
||||
const files = showAll ? diff.files : diff.files.slice(0, NATIVE_CHAT_TURN_DIFF_FILE_LIMIT)
|
||||
const remaining = diff.files.length - files.length
|
||||
return (
|
||||
<Collapsible className="text-xs text-muted-foreground">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="xs" className="group w-full min-w-0 justify-start gap-1.5">
|
||||
<span className="min-w-0 truncate">
|
||||
{translate('components.native-chat.turnDiff.changedFiles', '{{count}} changed files', {
|
||||
count: diff.files.length,
|
||||
defaultValue_one: '{{count}} changed file'
|
||||
})}
|
||||
</span>
|
||||
<DiffLineCounts added={diff.added} removed={diff.removed} />
|
||||
{diff.truncated ? (
|
||||
<span>{translate('components.native-chat.turnDiff.partial', 'Partial diff')}</span>
|
||||
) : null}
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-90 motion-reduce:transition-none"
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-1 space-y-1 pl-4">
|
||||
{diff.files.map((file) => (
|
||||
<TurnDiffFileRow key={file.path} file={file} onReveal={onReveal} />
|
||||
<div className="rounded-lg border border-chat-code-border p-2 text-xs text-chat-foreground-faint">
|
||||
<div className="flex min-w-0 items-center gap-1.5 px-1 pb-1">
|
||||
<span className="min-w-0 truncate">
|
||||
{translate('components.native-chat.turnDiff.changedFiles', '{{count}} changed files', {
|
||||
count: diff.files.length,
|
||||
defaultValue_one: '{{count}} changed file'
|
||||
})}
|
||||
</span>
|
||||
<DiffLineCounts added={diff.added} removed={diff.removed} />
|
||||
{diff.truncated ? (
|
||||
<span>{translate('components.native-chat.turnDiff.partial', 'Partial diff')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{files.map((file) => (
|
||||
<TurnDiffFileRow key={file.path} file={file} onLinkClick={onLinkClick} />
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{remaining > 0 ? (
|
||||
<Button variant="ghost" size="xs" onClick={() => setShowAll(true)}>
|
||||
{translate('components.native-chat.turnDiff.showMoreFiles', 'Show {{count}} more', {
|
||||
count: remaining
|
||||
})}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
|
||||
import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat/native-chat-resolution-receipt'
|
||||
import type { NativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots'
|
||||
import {
|
||||
buildNativeChatRailItems,
|
||||
|
||||
@@ -25,7 +25,7 @@ import { nativeChatSubagentSections } from './native-chat-subagent-sections'
|
||||
import { projectNativeChatTaskListFrames } from '../../../../shared/native-chat/input/native-chat-task-list-frames'
|
||||
import { omitNativeChatThreadGoalRows } from './native-chat-thread-goal-rows'
|
||||
import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots'
|
||||
import type { NativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
|
||||
|
||||
function row(sequence: number, body: AgentJournalItemBody, itemId = `item-${sequence}`) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Keep estimates arithmetic over cached metrics; per-measurement block scans would traverse the whole transcript.
|
||||
|
||||
import { NATIVE_CHAT_TURN_DIFF_FILE_LIMIT } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import { agentMessageIsCompact } from '../../../../shared/agent-session/agent-message-presentation'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
|
||||
import { deriveNativeChatRowContent } from '../../../../shared/native-chat/transcript/native-chat-row-content'
|
||||
@@ -24,6 +25,7 @@ export type NativeChatRowChromeMetrics = {
|
||||
hasReceipt: boolean
|
||||
hasStatus: boolean
|
||||
hasTurnDiff: boolean
|
||||
turnDiffFileCount?: number
|
||||
/** Folded prose/tools paint only a roster and must not reserve their expanded height. */
|
||||
folded?: boolean
|
||||
/** Inside a subagent's section, where assistant prose keeps its controls in flow. */
|
||||
@@ -176,7 +178,15 @@ export function estimateNativeChatRowHeight(
|
||||
partCount += 1
|
||||
}
|
||||
if (chrome.hasTurnDiff) {
|
||||
height += TURN_DIFF_PX * scale
|
||||
height +=
|
||||
TURN_DIFF_PX *
|
||||
scale *
|
||||
(1 +
|
||||
Math.min(chrome.turnDiffFileCount ?? 0, NATIVE_CHAT_TURN_DIFF_FILE_LIMIT) +
|
||||
(chrome.turnDiffFileCount !== undefined &&
|
||||
chrome.turnDiffFileCount > NATIVE_CHAT_TURN_DIFF_FILE_LIMIT
|
||||
? 1
|
||||
: 0))
|
||||
partCount += 1
|
||||
}
|
||||
height += Math.max(0, partCount - 1) * NATIVE_CHAT_ROW_GAP_PX
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type NativeChatMessageSlot,
|
||||
type NativeChatTranscriptSlot
|
||||
} from './native-chat-transcript-slots'
|
||||
import { nativeChatTurnDiffs } from './native-chat-turn-diffs'
|
||||
import { nativeChatTurnDiffs } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
|
||||
let sequence = 0
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '../../../../shared/native-chat/transcript/native-chat-turn-status'
|
||||
import { selectStructuredAgentSettledTurns } from '../../../../shared/agent-session/view/structured-agent-session-turn-timing'
|
||||
import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat/native-chat-resolution-receipt'
|
||||
import type { NativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import {
|
||||
buildNativeChatTranscriptSlots,
|
||||
nativeChatSlotIndexOf,
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
nativeChatRowContentMetrics
|
||||
} from './native-chat-row-height-estimate'
|
||||
import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat/native-chat-resolution-receipt'
|
||||
import type { NativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import {
|
||||
NO_NATIVE_CHAT_SUBAGENT_CHOICES,
|
||||
NO_NATIVE_CHAT_SUBAGENT_SECTIONS,
|
||||
@@ -205,6 +205,7 @@ export function buildNativeChatTranscriptSlots(
|
||||
hasReceipt: receipt !== undefined,
|
||||
hasStatus: status !== undefined,
|
||||
hasTurnDiff: turnDiff !== undefined,
|
||||
turnDiffFileCount: turnDiff?.files.length,
|
||||
folded
|
||||
},
|
||||
typography
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
|
||||
import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat/native-chat-resolution-receipt'
|
||||
import type { NativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import type { NativeChatTurnStatus } from '../../../../shared/native-chat/transcript/native-chat-turn-status'
|
||||
import {
|
||||
buildNativeChatTranscriptSlots,
|
||||
|
||||
@@ -58,6 +58,7 @@ function workRunSlot(
|
||||
hasReceipt: false,
|
||||
hasStatus: head.status !== undefined,
|
||||
hasTurnDiff: turnDiff !== undefined,
|
||||
turnDiffFileCount: turnDiff?.files.length,
|
||||
inSubagentSection: head.depth > 0
|
||||
},
|
||||
typography
|
||||
|
||||
@@ -6,10 +6,13 @@ import type {
|
||||
} from '../../../../shared/native-chat/native-chat-types'
|
||||
import { foldToolMessages } from '../../../../shared/native-chat/tools/native-chat-tool-fold'
|
||||
import {
|
||||
buildDiffSummaries,
|
||||
buildEditSummaries,
|
||||
buildEditCards
|
||||
} from '../../../../shared/native-chat/tools/native-chat-edit-cards'
|
||||
import { nativeChatTurnDiffEditFile, nativeChatTurnDiffs } from './native-chat-turn-diffs'
|
||||
import {
|
||||
nativeChatTurnDiffEditFile,
|
||||
nativeChatTurnDiffs
|
||||
} from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
|
||||
function diff(id: string, path: string, patch = '@@ -1 +1 @@\n-old\n+new'): NativeChatMessage {
|
||||
return {
|
||||
@@ -172,18 +175,10 @@ describe('turn diff rollups', () => {
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('leaves non-journal Diff envelopes to the deferred tool card', () => {
|
||||
for (const input of [{ path: 'x', patch: '@@\n+override' }, { file_path: 'x' }, null]) {
|
||||
const message = diff('generic', 'x')
|
||||
message.blocks[0] = { type: 'tool-call', name: 'Diff', input }
|
||||
expect(buildDiffSummaries(message.blocks).size).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('caches counts and deferred card models separately and refreshes new results', () => {
|
||||
const message = diff('a', 'a.ts')
|
||||
const summary = [...buildDiffSummaries(message.blocks).values()][0]!.files
|
||||
expect([...buildDiffSummaries([...message.blocks]).values()][0]!.files).toBe(summary)
|
||||
const summary = [...buildEditSummaries(message.blocks).values()][0]!.files
|
||||
expect([...buildEditSummaries([...message.blocks]).values()][0]!.files).toBe(summary)
|
||||
expect(summary[0]).not.toHaveProperty('lines')
|
||||
const first = [...buildEditCards(message.blocks).editCards.values()][0]!.files
|
||||
expect([...buildEditCards([...message.blocks]).editCards.values()][0]!.files).toBe(first)
|
||||
@@ -194,7 +189,7 @@ describe('turn diff rollups', () => {
|
||||
const updated = [...buildEditCards(message.blocks).editCards.values()][0]!.files
|
||||
expect(updated).not.toBe(first)
|
||||
expect(updated[0]?.added).toBe(2)
|
||||
const updatedSummary = [...buildDiffSummaries(message.blocks).values()][0]!.files
|
||||
const updatedSummary = [...buildEditSummaries(message.blocks).values()][0]!.files
|
||||
expect(updatedSummary).not.toBe(summary)
|
||||
expect(updatedSummary[0]?.added).toBe(2)
|
||||
})
|
||||
@@ -209,10 +204,41 @@ describe('turn diff rollups', () => {
|
||||
'@@ -1 +1 @@\n-old\n+new\n… (9999 bytes)'
|
||||
])('keeps lightweight counts identical to the expanded card (case %#)', (patch) => {
|
||||
const message = diff('parity', 'a.ts', patch)
|
||||
const summary = [...buildDiffSummaries(message.blocks).values()][0]!.files
|
||||
const summary = [...buildEditSummaries(message.blocks).values()][0]!.files
|
||||
const detailed = [...buildEditCards(message.blocks).editCards.values()][0]!.files
|
||||
expect(summary).toEqual(
|
||||
detailed.map(({ lines: _lines, lineNumbersKnown: _known, ...file }) => file)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('summarizes landed Claude and Codex edits, including a completed call without a result', () => {
|
||||
const message: NativeChatMessage = {
|
||||
id: 'tools',
|
||||
role: 'assistant',
|
||||
timestamp: 1,
|
||||
source: 'transcript',
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'Edit',
|
||||
state: 'completed',
|
||||
input: { file_path: '/repo/a.ts', old_string: 'before', new_string: 'after' }
|
||||
},
|
||||
{ type: 'tool-result', output: 'The file /repo/a.ts has been updated successfully.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'apply_patch',
|
||||
state: 'completed',
|
||||
input: { patch: '*** Begin Patch\n*** Add File: b.ts\n+added\n*** End Patch' }
|
||||
}
|
||||
]
|
||||
}
|
||||
const turn = nativeChatTurnDiffs([message], ['u']).get('u')!
|
||||
expect(turn.files.map((file) => [file.path, file.added, file.removed])).toEqual([
|
||||
['/repo/a.ts', 1, 1],
|
||||
['b.ts', 1, 0]
|
||||
])
|
||||
expect(turn.files[1]!.edits.map(nativeChatTurnDiffEditFile)[0]?.lines[0]?.text).toBe('added')
|
||||
expect(nativeChatTurnDiffs([message], ['u'], undefined, 'u').get('u')?.truncated).toBe(true)
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as rowContent from '../../../../shared/native-chat/transcript/native-ch
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat/native-chat-types'
|
||||
import type { NativeChatResolvedPrompt } from '../../../../shared/native-chat/native-chat-resolution-receipt'
|
||||
import type { NativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
import type { NativeChatTurnDiff } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import { buildNativeChatTranscriptSlots } from './native-chat-transcript-slots'
|
||||
import { useNativeChatMessageRail } from './use-native-chat-message-rail'
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { NativeChatSubagentRow } from '../../../../shared/native-chat/transcript/native-chat-transcript-projection'
|
||||
import { nativeChatRowsInTranscriptOrder } from './native-chat-subagent-sections'
|
||||
import { nativeChatTurnDiffs, type NativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
import {
|
||||
nativeChatTurnDiffs,
|
||||
type NativeChatTurnDiff
|
||||
} from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
import type { NativeChatTurnRows } from './use-native-chat-turn-membership'
|
||||
|
||||
const NO_TURN_DIFFS: ReadonlyMap<string, NativeChatTurnDiff> = new Map()
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '../../../../shared/native-chat/native-chat-work-run'
|
||||
import { NativeChatReasoningRow } from './NativeChatReasoningRow'
|
||||
import type { NativeChatToolRunAsides } from './NativeChatToolRun'
|
||||
import type { NativeChatDiffReveal } from './native-chat-turn-diffs'
|
||||
import type { NativeChatDiffReveal } from '../../../../shared/native-chat/tools/native-chat-turn-diffs'
|
||||
|
||||
export type NativeChatWorkRun = {
|
||||
/** Every member's tool blocks, in order: the run's one block list. */
|
||||
|
||||
@@ -19336,8 +19336,6 @@
|
||||
},
|
||||
"turnDiff": {
|
||||
"partial": "Teilweiser Diff",
|
||||
"showDiff": "Diff anzeigen",
|
||||
"hideDiff": "Diff ausblenden",
|
||||
"changedFiles_one": "{{count}} geänderte Datei",
|
||||
"changedFiles_other": "{{count}} geänderte Dateien"
|
||||
},
|
||||
|
||||
@@ -19729,10 +19729,9 @@
|
||||
},
|
||||
"turnDiff": {
|
||||
"partial": "Partial diff",
|
||||
"showDiff": "Show diff",
|
||||
"hideDiff": "Hide diff",
|
||||
"changedFiles_one": "{{count}} changed file",
|
||||
"changedFiles_other": "{{count}} changed files"
|
||||
"changedFiles_other": "{{count}} changed files",
|
||||
"showMoreFiles": "Show {{count}} more"
|
||||
},
|
||||
"receipt": {
|
||||
"unavailable": "Selected answer unavailable",
|
||||
|
||||
@@ -19584,8 +19584,6 @@
|
||||
},
|
||||
"turnDiff": {
|
||||
"partial": "Diff parcial",
|
||||
"showDiff": "Mostrar diferencias",
|
||||
"hideDiff": "Ocultar diferencias",
|
||||
"changedFiles_one": "{{count}} archivo modificado",
|
||||
"changedFiles_other": "{{count}} archivos modificados",
|
||||
"changedFiles_many": "{{count}} archivos modificados"
|
||||
|
||||
@@ -19635,8 +19635,6 @@
|
||||
},
|
||||
"turnDiff": {
|
||||
"partial": "Différence partielle",
|
||||
"showDiff": "Afficher les différences",
|
||||
"hideDiff": "Masquer les différences",
|
||||
"changedFiles_one": "{{count}} fichier modifié",
|
||||
"changedFiles_other": "{{count}} fichiers modifiés",
|
||||
"changedFiles_many": "{{count}} fichiers modifiés"
|
||||
|
||||
@@ -18950,8 +18950,6 @@
|
||||
},
|
||||
"turnDiff": {
|
||||
"partial": "部分的な差分",
|
||||
"showDiff": "差分を表示",
|
||||
"hideDiff": "差分を非表示",
|
||||
"changedFiles_other": "変更されたファイル {{count}} 個"
|
||||
},
|
||||
"receipt": {
|
||||
|
||||
@@ -18950,8 +18950,6 @@
|
||||
},
|
||||
"turnDiff": {
|
||||
"partial": "부분적인 차이점",
|
||||
"showDiff": "차이 보기",
|
||||
"hideDiff": "차이 숨기기",
|
||||
"changedFiles_other": "변경된 파일 {{count}}개"
|
||||
},
|
||||
"receipt": {
|
||||
|
||||
@@ -20050,8 +20050,6 @@
|
||||
},
|
||||
"turnDiff": {
|
||||
"partial": "Diff parcial",
|
||||
"showDiff": "Mostrar diferenças",
|
||||
"hideDiff": "Ocultar diferenças",
|
||||
"changedFiles_one": "{{count}} arquivo alterado",
|
||||
"changedFiles_other": "{{count}} arquivos alterados",
|
||||
"changedFiles_many": "{{count}} arquivos alterados"
|
||||
|
||||
@@ -19911,8 +19911,6 @@
|
||||
},
|
||||
"turnDiff": {
|
||||
"partial": "Показана часть изменений",
|
||||
"showDiff": "Показать изменения",
|
||||
"hideDiff": "Скрыть изменения",
|
||||
"changedFiles_one": "Изменён {{count}} файл",
|
||||
"changedFiles_few": "Изменено {{count}} файла",
|
||||
"changedFiles_many": "Изменено {{count}} файлов",
|
||||
|
||||
@@ -19323,8 +19323,6 @@
|
||||
},
|
||||
"turnDiff": {
|
||||
"partial": "Kısmi diff",
|
||||
"showDiff": "Diff'i göster",
|
||||
"hideDiff": "Diff'i gizle",
|
||||
"changedFiles_other": "Değiştirilen {{count}} dosya",
|
||||
"changedFiles_one": "Değiştirilen {{count}} dosya"
|
||||
},
|
||||
|
||||
@@ -19078,8 +19078,6 @@
|
||||
},
|
||||
"turnDiff": {
|
||||
"partial": "部分差異",
|
||||
"showDiff": "顯示差異",
|
||||
"hideDiff": "隱藏差異",
|
||||
"changedFiles_other": "{{count}} 個已變更的檔案"
|
||||
},
|
||||
"receipt": {
|
||||
|
||||
@@ -18895,8 +18895,6 @@
|
||||
},
|
||||
"turnDiff": {
|
||||
"partial": "部分差异",
|
||||
"showDiff": "显示差异",
|
||||
"hideDiff": "隐藏差异",
|
||||
"changedFiles_other": "{{count}} 个已更改的文件"
|
||||
},
|
||||
"receipt": {
|
||||
|
||||
@@ -96,45 +96,54 @@ const diffSummaries = new WeakMap<
|
||||
>()
|
||||
|
||||
// Only the journal's path-only Diff envelope has counts that can be read without tool normalization.
|
||||
export function buildDiffSummaries(blocks: NativeChatBlock[]): Map<
|
||||
export function buildEditSummaries(blocks: NativeChatBlock[]): Map<
|
||||
NativeChatToolCallBlock,
|
||||
{
|
||||
files: NativeChatEditFileSummary[]
|
||||
key: string
|
||||
result: NativeChatToolResultBlock
|
||||
result: NativeChatToolResultBlock | undefined
|
||||
}
|
||||
> {
|
||||
const summaries = new Map<
|
||||
NativeChatToolCallBlock,
|
||||
{ files: NativeChatEditFileSummary[]; key: string; result: NativeChatToolResultBlock }
|
||||
{
|
||||
files: NativeChatEditFileSummary[]
|
||||
key: string
|
||||
result: NativeChatToolResultBlock | undefined
|
||||
}
|
||||
>()
|
||||
for (const [index, pair] of pairToolBlocks(blocks).entries()) {
|
||||
const { call, result } = pair
|
||||
if (
|
||||
!call ||
|
||||
call.name !== 'Diff' ||
|
||||
call.state === 'running' ||
|
||||
call.state === 'failed' ||
|
||||
result?.isError ||
|
||||
result?.editPatch ||
|
||||
!result?.output
|
||||
) {
|
||||
if (!call || !isEditToolName(call.name)) {
|
||||
continue
|
||||
}
|
||||
const input = call.input
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== 'object' ||
|
||||
!('path' in input) ||
|
||||
typeof input.path !== 'string' ||
|
||||
Object.keys(input).some((key) => key !== 'path')
|
||||
) {
|
||||
const journalDiff =
|
||||
call.name === 'Diff' &&
|
||||
call.state !== 'running' &&
|
||||
call.state !== 'failed' &&
|
||||
result &&
|
||||
!result.isError &&
|
||||
!result.editPatch &&
|
||||
result.output &&
|
||||
input &&
|
||||
typeof input === 'object' &&
|
||||
'path' in input &&
|
||||
typeof input.path === 'string' &&
|
||||
Object.keys(input).every((key) => key === 'path')
|
||||
? { output: result.output, path: input.path }
|
||||
: undefined
|
||||
if (!journalDiff) {
|
||||
const files = normalizedToolEditFiles(call, result)
|
||||
if (files?.length) {
|
||||
summaries.set(call, { files, key: `${call.name}:${index}`, result })
|
||||
}
|
||||
continue
|
||||
}
|
||||
const cached = diffSummaries.get(call)
|
||||
let files = cached?.result === result ? cached.files : undefined
|
||||
let files = cached && cached.result === result ? cached.files : undefined
|
||||
if (files === undefined) {
|
||||
files = editFilesFromPatchText(result.output, input.path, true)
|
||||
files = editFilesFromPatchText(journalDiff.output, journalDiff.path, true)
|
||||
diffSummaries.set(call, { result, files })
|
||||
}
|
||||
if (files?.length) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../native-chat-types'
|
||||
import { nativeChatTurnDiffs, sameNativeChatTurnDiff } from './native-chat-turn-diffs'
|
||||
|
||||
// Count source normalization separately from rendered patches: unopened summaries still consume input.
|
||||
describe('landed edit summary work', () => {
|
||||
it.each([100, 1000])('normalizes %i Claude edits once and only new input on append', (count) => {
|
||||
let contentReads = 0
|
||||
const edit = (index: number): NativeChatMessage => ({
|
||||
id: `edit-${index}`,
|
||||
role: 'assistant',
|
||||
source: 'transcript',
|
||||
timestamp: index,
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'Edit',
|
||||
state: 'completed',
|
||||
input: {
|
||||
file_path: `/repo/file-${index}.ts`,
|
||||
old_string: 'old-line\n'.repeat(40),
|
||||
get new_string() {
|
||||
contentReads += 1
|
||||
return 'new-line\n'.repeat(40)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'tool-result', output: 'The file was updated successfully.' }
|
||||
]
|
||||
})
|
||||
const messages = Array.from({ length: count }, (_, index) => edit(index))
|
||||
const keys = messages.map(() => 'u')
|
||||
const first = nativeChatTurnDiffs(messages, keys).get('u')!
|
||||
expect(first.files).toHaveLength(count)
|
||||
const coldReads = contentReads
|
||||
expect(coldReads).toBeGreaterThanOrEqual(count)
|
||||
expect(coldReads).toBeLessThanOrEqual(count * 4)
|
||||
const same = nativeChatTurnDiffs([...messages], [...keys]).get('u')!
|
||||
expect(contentReads).toBe(coldReads)
|
||||
expect(sameNativeChatTurnDiff(first, same)).toBe(true)
|
||||
const appended = nativeChatTurnDiffs([...messages, edit(count)], [...keys, 'u']).get('u')!
|
||||
expect(appended.files).toHaveLength(count + 1)
|
||||
expect(contentReads - coldReads).toBe(coldReads / count)
|
||||
expect(sameNativeChatTurnDiff(first, appended)).toBe(false)
|
||||
const refreshed = [...messages]
|
||||
const last = refreshed[count - 1]!
|
||||
refreshed[count - 1] = {
|
||||
...last,
|
||||
blocks: [last.blocks[0]!, { type: 'tool-result', output: 'Updated successfully.' }]
|
||||
}
|
||||
const beforeResult = contentReads
|
||||
nativeChatTurnDiffs(refreshed, keys)
|
||||
expect(contentReads - beforeResult).toBe(coldReads / count)
|
||||
})
|
||||
})
|
||||
+45
-10
@@ -1,13 +1,10 @@
|
||||
import type { NativeChatEditFile } from '../../../../shared/native-chat/tools/native-chat-edit-model'
|
||||
import type { NativeChatEditFile } from './native-chat-edit-model'
|
||||
import type {
|
||||
NativeChatMessage,
|
||||
NativeChatToolCallBlock,
|
||||
NativeChatToolResultBlock
|
||||
} from '../../../../shared/native-chat/native-chat-types'
|
||||
import {
|
||||
buildDiffSummaries,
|
||||
normalizedToolEditFiles
|
||||
} from '../../../../shared/native-chat/tools/native-chat-edit-cards'
|
||||
} from '../native-chat-types'
|
||||
import { buildEditSummaries, normalizedToolEditFiles } from './native-chat-edit-cards'
|
||||
|
||||
export type NativeChatDiffTarget = {
|
||||
messageId: string
|
||||
@@ -21,7 +18,7 @@ export type NativeChatDiffReveal = NativeChatDiffTarget & { requestId: number }
|
||||
|
||||
export type NativeChatTurnDiffEdit = {
|
||||
call: NativeChatToolCallBlock
|
||||
result: NativeChatToolResultBlock
|
||||
result: NativeChatToolResultBlock | undefined
|
||||
fileIndex: number
|
||||
target: NativeChatDiffTarget
|
||||
order: number
|
||||
@@ -61,11 +58,11 @@ export function nativeChatTurnDiffs(
|
||||
let editOrder = 0
|
||||
for (const [index, message] of messages.entries()) {
|
||||
const turnKey = turnKeys[index]
|
||||
if (!turnKey || turnKey === partialTurnKey) {
|
||||
if (!turnKey) {
|
||||
continue
|
||||
}
|
||||
const sections = subagentSectionsOf?.get(message.id)
|
||||
for (const [call, edit] of buildDiffSummaries(message.blocks)) {
|
||||
for (const [call, edit] of buildEditSummaries(message.blocks)) {
|
||||
let files = turns.get(turnKey)
|
||||
if (!files) {
|
||||
files = new Map()
|
||||
@@ -119,9 +116,47 @@ export function nativeChatTurnDiffs(
|
||||
files,
|
||||
added: files.reduce((sum, file) => sum + file.added, 0),
|
||||
removed: files.reduce((sum, file) => sum + file.removed, 0),
|
||||
truncated: files.some((file) => file.truncated)
|
||||
truncated: key === partialTurnKey || files.some((file) => file.truncated)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export const NATIVE_CHAT_TURN_DIFF_FILE_LIMIT = 20
|
||||
|
||||
/** Compare recorded inputs so unrelated streaming turns do not repaint existing outcome cards. */
|
||||
export function sameNativeChatTurnDiff(
|
||||
left: NativeChatTurnDiff | undefined,
|
||||
right: NativeChatTurnDiff | undefined
|
||||
): boolean {
|
||||
return (
|
||||
left === right ||
|
||||
(!!left &&
|
||||
!!right &&
|
||||
left.added === right.added &&
|
||||
left.removed === right.removed &&
|
||||
left.truncated === right.truncated &&
|
||||
left.files.length === right.files.length &&
|
||||
left.files.every((file, index) => {
|
||||
const other = right.files[index]!
|
||||
return (
|
||||
file.path === other.path &&
|
||||
file.added === other.added &&
|
||||
file.removed === other.removed &&
|
||||
file.truncated === other.truncated &&
|
||||
file.edits.length === other.edits.length &&
|
||||
file.edits.every((edit, editIndex) => {
|
||||
const candidate = other.edits[editIndex]!
|
||||
return (
|
||||
edit.call === candidate.call &&
|
||||
edit.result === candidate.result &&
|
||||
edit.fileIndex === candidate.fileIndex &&
|
||||
edit.target.messageId === candidate.target.messageId &&
|
||||
edit.target.editKey === candidate.target.editKey
|
||||
)
|
||||
})
|
||||
)
|
||||
}))
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user