feat(web): group consecutive tool-use cards (#604)

* feat(web): group consecutive tool-use cards

Add a web-only visible projection that groups consecutive root-level execution tools into expandable cards.
Keep approval and question tools standalone, reuse older-history loading on expand, and add regression coverage for grouping and UI behavior.

* fix(web): hydrate oldest visible tool group

Mark needsOlderHistory on the first visible grouped tool run even when earlier visible blocks are non-tool content, and add regression coverage for the boundary.

* fix(web): continue grouped history hydration

Decouple ToolGroupCard older-history chaining from the shared loading flag, invalidate stale hydration runs safely, and add regression coverage for multi-page hydration.

* fix(web): harden grouped tool hydration

- retry incomplete group hydration after transient pagination contention\n- keep approved and denied permissioned tool cards eligible for grouping\n- cover both regressions with targeted web tests

* fix(web): keep Codex permission cards standalone

- treat CodexPermission as a semantic grouping boundary even after approval\n- keep permissioned execution tools groupable while preserving permission milestones\n- add regression coverage for Codex permission eligibility and boundary behavior

* fix(web): narrow incomplete tool-group hydration

- only mark groups at the oldest visible boundary as needing older history\n- avoid auto-paginating complete groups behind text, standalone tools, or permission milestones\n- add regression coverage for the adjacent boundary cases
This commit is contained in:
junes
2026-05-11 09:25:49 +08:00
committed by GitHub
parent 77f6aae169
commit af3491e046
13 changed files with 1575 additions and 39 deletions
+17 -1
View File
@@ -16,6 +16,7 @@ import { normalizeDecryptedMessage } from '@/chat/normalize'
import { reduceChatBlocks } from '@/chat/reducer'
import { reconcileChatBlocks } from '@/chat/reconcile'
import { buildConversationOutline } from '@/chat/outline'
import { buildVisibleChatBlocks, isToolGroupBlock, type ToolGroupBlock } from '@/chat/toolGroups'
import { isQueuedForInvocation } from '@/lib/messages'
import { HappyComposer } from '@/components/AssistantChat/HappyComposer'
import { HappyThread } from '@/components/AssistantChat/HappyThread'
@@ -74,6 +75,7 @@ export function SessionChat(props: {
const terminalSupported = isRemoteTerminalSupported(props.session.metadata)
const normalizedCacheRef = useRef<Map<string, { source: DecryptedMessage; normalized: NormalizedMessage | null }>>(new Map())
const blocksByIdRef = useRef<Map<string, ChatBlock>>(new Map())
const visibleGroupsRef = useRef<ToolGroupBlock[]>([])
const [forceScrollToken, setForceScrollToken] = useState(0)
const [outlineOpen, setOutlineOpen] = useState(false)
const agentFlavor = props.session.metadata?.flavor ?? null
@@ -224,6 +226,7 @@ export function SessionChat(props: {
useEffect(() => {
normalizedCacheRef.current.clear()
blocksByIdRef.current.clear()
visibleGroupsRef.current = []
setOutlineOpen(false)
}, [props.session.id])
@@ -241,6 +244,7 @@ export function SessionChat(props: {
if (prevSessionIdRef.current !== null && prevSessionIdRef.current !== props.session.id) {
normalizedCacheRef.current.clear()
blocksByIdRef.current.clear()
visibleGroupsRef.current = []
}
prevSessionIdRef.current = props.session.id
@@ -279,6 +283,18 @@ export function SessionChat(props: {
blocksByIdRef.current = reconciled.byId
}, [reconciled.byId])
const visibleBlocks = useMemo(
() => buildVisibleChatBlocks(reconciled.blocks, {
hasMoreMessages: props.hasMoreMessages,
previousGroups: visibleGroupsRef.current
}),
[reconciled.blocks, props.hasMoreMessages]
)
useEffect(() => {
visibleGroupsRef.current = visibleBlocks.filter(isToolGroupBlock)
}, [visibleBlocks])
const outlineItems = useMemo(
() => buildConversationOutline(reconciled.blocks),
[reconciled.blocks]
@@ -386,7 +402,7 @@ export function SessionChat(props: {
const runtime = useHappyRuntime({
session: props.session,
blocks: reconciled.blocks,
blocks: visibleBlocks,
isSending: props.isSending,
onSendMessage: handleSend,
onAbort: handleAbort,