| author | |
| committer | |
| log | 3486451525ecaf3a8379aa6d08c885c7cc5335e3 |
| tree | 5884525533adf49328c76a4c96ab515b7362ab18 |
| parent | c542b781c6c4c766e66dcb764b2142d95600e890 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 157 insertions(+), 11 deletions(-)
apps/mobile/src/lib/threadActivity.test.ts+54-3| ... | ... | @@ -3360,7 +3360,8 @@ it("accepts ready attachment-only answers while preserving selected options", () |
| 3360 | 3360 | ).toBeNull(); |
| 3361 | 3361 | }); |
| 3362 | 3362 | |
| 3363 | it("makes attachment-only question answers expandable in the mobile feed", () => { | |
| 3363 | it("keeps attachment-only question answers expandable outside mobile work groups and turn folds", () => { | |
| 3364 | const turnId = TurnId.make("turn-answer"); | |
| 3364 | 3365 | const answer = { |
| 3365 | 3366 | requestId: ApprovalRequestId.make("question-request"), |
| 3366 | 3367 | answers: { q: "" }, |
| ... | ... | @@ -3381,17 +3382,46 @@ it("makes attachment-only question answers expandable in the mobile feed", () => |
| 3381 | 3382 | id: ThreadId.make("thread-answer"), |
| 3382 | 3383 | projectId: ProjectId.make("project-answer"), |
| 3383 | 3384 | title: "Answer history", |
| 3385 | latestTurn: { | |
| 3386 | turnId, | |
| 3387 | state: "completed", | |
| 3388 | requestedAt: "2026-09-08T00:00:00.000Z", | |
| 3389 | startedAt: "2026-09-08T00:00:00.000Z", | |
| 3390 | completedAt: "2026-09-08T00:00:04.000Z", | |
| 3391 | assistantMessageId: null, | |
| 3392 | }, | |
| 3384 | 3393 | activities: [ |
| 3394 | makeActivity({ | |
| 3395 | id: EventId.make("tool-before-answer"), | |
| 3396 | createdAt: "2026-09-08T00:00:01.000Z", | |
| 3397 | kind: "tool.completed", | |
| 3398 | tone: "tool", | |
| 3399 | summary: "Read files", | |
| 3400 | turnId, | |
| 3401 | payload: { itemType: "command_execution", status: "completed" }, | |
| 3402 | }), | |
| 3385 | 3403 | makeActivity({ |
| 3386 | 3404 | id: EventId.make("answer-submitted"), |
| 3387 | createdAt: "2026-09-08T00:00:00.000Z", | |
| 3405 | createdAt: "2026-09-08T00:00:02.000Z", | |
| 3388 | 3406 | kind: "user-input.answer-submitted", |
| 3389 | 3407 | summary: "Answered questions", |
| 3408 | turnId, | |
| 3390 | 3409 | payload: answer, |
| 3391 | 3410 | }), |
| 3411 | makeActivity({ | |
| 3412 | id: EventId.make("tool-after-answer"), | |
| 3413 | createdAt: "2026-09-08T00:00:03.000Z", | |
| 3414 | kind: "tool.completed", | |
| 3415 | tone: "tool", | |
| 3416 | summary: "Read files", | |
| 3417 | turnId, | |
| 3418 | payload: { itemType: "command_execution", status: "completed" }, | |
| 3419 | }), | |
| 3392 | 3420 | ], |
| 3393 | 3421 | }); |
| 3394 | const [group] = buildThreadFeed(thread); | |
| 3422 | const feed = buildThreadFeed(thread); | |
| 3423 | expect(feed).toHaveLength(3); | |
| 3424 | const group = feed[1]; | |
| 3395 | 3425 | expect(group?.type).toBe("activity-group"); |
| 3396 | 3426 | if (group?.type !== "activity-group") return; |
| 3397 | 3427 | expect(group.activities[0]).toMatchObject({ |
| ... | ... | @@ -3399,4 +3429,25 @@ it("makes attachment-only question answers expandable in the mobile feed", () => |
| 3399 | 3429 | workEntry: { questionAnswer: answer }, |
| 3400 | 3430 | }); |
| 3401 | 3431 | expect(group.activities[0]?.getFullDetail()).toBeNull(); |
| 3432 | const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); | |
| 3433 | expect(collapsed.map((entry) => entry.type)).toEqual(["turn-fold", "activity-group"]); | |
| 3434 | expect(collapsed[1]).toBe(group); | |
| 3435 | const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); | |
| 3436 | expect(expanded.map((entry) => entry.type)).toEqual([ | |
| 3437 | "turn-fold", | |
| 3438 | "work-toggle", | |
| 3439 | "activity-group", | |
| 3440 | "work-toggle", | |
| 3441 | ]); | |
| 3442 | expect(expanded[2]).toBe(group); | |
| 3443 | const running = deriveThreadFeedPresentation( | |
| 3444 | feed, | |
| 3445 | { ...thread.latestTurn!, state: "running", completedAt: null }, | |
| 3446 | new Set(), | |
| 3447 | new Set(), | |
| 3448 | "2026-09-08T00:00:00.000Z", | |
| 3449 | ); | |
| 3450 | expect(running[0]?.type).toBe("work-toggle"); | |
| 3451 | expect(running[1]).toBe(group); | |
| 3452 | expect(running[2]?.type).toBe("work-toggle"); | |
| 3402 | 3453 | }); |
apps/mobile/src/lib/threadActivity.ts+13-5| ... | ... | @@ -264,6 +264,10 @@ export function isContextCompactionActivityGroup( |
| 264 | 264 | ); |
| 265 | 265 | } |
| 266 | 266 | |
| 267 | function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean { | |
| 268 | return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined); | |
| 269 | } | |
| 270 | ||
| 267 | 271 | function normalizeDraftAnswer(value: string | undefined): string | null { |
| 268 | 272 | if (typeof value !== "string") { |
| 269 | 273 | return null; |
| ... | ... | @@ -1556,13 +1560,15 @@ function groupAdjacentActivities(entries: ReadonlyArray<RawThreadFeedEntry>): Th |
| 1556 | 1560 | continue; |
| 1557 | 1561 | } |
| 1558 | 1562 | |
| 1559 | const isCompaction = entry.activity.workEntry.sourceActivityKind === "context-compaction"; | |
| 1560 | if (isCompaction || firstActivityEntry?.turnId !== entry.turnId) { | |
| 1563 | const isStandalone = | |
| 1564 | entry.activity.workEntry.sourceActivityKind === "context-compaction" || | |
| 1565 | entry.activity.workEntry.questionAnswer !== undefined; | |
| 1566 | if (isStandalone || firstActivityEntry?.turnId !== entry.turnId) { | |
| 1561 | 1567 | flushGroup(); |
| 1562 | 1568 | } |
| 1563 | 1569 | firstActivityEntry ??= entry; |
| 1564 | 1570 | openGroupActivities.push(entry.activity); |
| 1565 | if (isCompaction) { | |
| 1571 | if (isStandalone) { | |
| 1566 | 1572 | flushGroup(); |
| 1567 | 1573 | } |
| 1568 | 1574 | } |
| ... | ... | @@ -1668,7 +1674,9 @@ function deriveThreadFeedTurnFolds( |
| 1668 | 1674 | entries |
| 1669 | 1675 | .filter( |
| 1670 | 1676 | (entry) => |
| 1671 | entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId, | |
| 1677 | entry.id !== firstAssistantMessageId && | |
| 1678 | entry.id !== terminalAssistantMessageId && | |
| 1679 | !(entry.type === "activity-group" && isUserInputActivityGroup(entry)), | |
| 1672 | 1680 | ) |
| 1673 | 1681 | .map((entry) => entry.id), |
| 1674 | 1682 | ); |
| ... | ... | @@ -1848,7 +1856,7 @@ function appendPresentedFeedEntry( |
| 1848 | 1856 | result.push(entry); |
| 1849 | 1857 | return; |
| 1850 | 1858 | } |
| 1851 | if (isContextCompactionActivityGroup(entry)) { | |
| 1859 | if (isContextCompactionActivityGroup(entry) || isUserInputActivityGroup(entry)) { | |
| 1852 | 1860 | result.push(entry); |
| 1853 | 1861 | return; |
| 1854 | 1862 | } |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+79| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | import { |
| 3 | ApprovalRequestId, | |
| 3 | 4 | CheckpointRef, |
| 4 | 5 | EnvironmentId, |
| 5 | 6 | EventId, |
| ... | ... | @@ -2622,6 +2623,84 @@ describe("deriveMessagesTimelineRows", () => { |
| 2622 | 2623 | }); |
| 2623 | 2624 | }); |
| 2624 | 2625 | |
| 2626 | it("keeps user input in its own row through tool grouping and turn folding", () => { | |
| 2627 | const turnId = TurnId.make("answer-turn"); | |
| 2628 | const time = (second: number) => new Date(Date.UTC(2026, 8, 8, 0, 0, second)).toISOString(); | |
| 2629 | const answer: WorkLogEntry = { | |
| 2630 | id: "answer-submitted", | |
| 2631 | createdAt: time(3), | |
| 2632 | turnId, | |
| 2633 | tone: "info", | |
| 2634 | label: "User input submitted", | |
| 2635 | sourceActivityKind: "user-input.answer-submitted", | |
| 2636 | questionAnswer: { | |
| 2637 | requestId: ApprovalRequestId.make("answer-request"), | |
| 2638 | answers: { scope: "Use the private repository" }, | |
| 2639 | questionTextById: { scope: "Which repository?" }, | |
| 2640 | attachmentsByQuestionId: {}, | |
| 2641 | }, | |
| 2642 | }; | |
| 2643 | const tools: WorkLogEntry[] = [1, 2, 4, 5].map((second) => ({ | |
| 2644 | id: `tool-${second}`, | |
| 2645 | createdAt: time(second), | |
| 2646 | turnId, | |
| 2647 | tone: "tool", | |
| 2648 | label: "Ran command", | |
| 2649 | command: "git status", | |
| 2650 | toolCallId: `call-${second}`, | |
| 2651 | toolLifecycleStatus: "completed", | |
| 2652 | sourceActivityKind: "tool.completed", | |
| 2653 | })); | |
| 2654 | const input = { | |
| 2655 | timelineEntries: deriveTimelineEntries([], [], [...tools, answer]), | |
| 2656 | latestTurn: { turnId, state: "completed", startedAt: time(0), completedAt: time(6) }, | |
| 2657 | isWorking: false, | |
| 2658 | activeTurnStartedAt: null, | |
| 2659 | turnDiffSummaries: [], | |
| 2660 | supportsConversationRollback: false, | |
| 2661 | } satisfies Parameters<typeof deriveMessagesTimelineRows>[0]; | |
| 2662 | const collapsed = deriveMessagesTimelineRows(input); | |
| 2663 | expect(collapsed.map((row) => row.kind)).toEqual(["turn-fold", "work"]); | |
| 2664 | const expanded = deriveMessagesTimelineRows({ ...input, expandedTurnIds: new Set([turnId]) }); | |
| 2665 | expect(expanded.map((row) => row.kind)).toEqual([ | |
| 2666 | "turn-fold", | |
| 2667 | "work-toggle", | |
| 2668 | "work", | |
| 2669 | "work-toggle", | |
| 2670 | ]); | |
| 2671 | const expandedGroups = deriveMessagesTimelineRows({ | |
| 2672 | ...input, | |
| 2673 | expandedTurnIds: new Set([turnId]), | |
| 2674 | expandedWorkGroupIds: new Set( | |
| 2675 | expanded.flatMap((row) => (row.kind === "work-toggle" ? [row.groupId] : [])), | |
| 2676 | ), | |
| 2677 | }); | |
| 2678 | expect( | |
| 2679 | expandedGroups.flatMap((row) => | |
| 2680 | row.kind === "work" && row.isExpandedToolGroup ? [row.groupedEntries] : [], | |
| 2681 | ), | |
| 2682 | ).toEqual([tools.slice(0, 2), tools.slice(2)]); | |
| 2683 | const active = deriveMessagesTimelineRows({ | |
| 2684 | ...input, | |
| 2685 | latestTurn: { ...input.latestTurn, state: "running", completedAt: null }, | |
| 2686 | runningTurnId: turnId, | |
| 2687 | isWorking: true, | |
| 2688 | activeTurnStartedAt: time(0), | |
| 2689 | }); | |
| 2690 | for (const rows of [collapsed, expanded, expandedGroups, active]) { | |
| 2691 | const answerRows = rows.filter( | |
| 2692 | (row) => | |
| 2693 | (row.kind === "work" || row.kind === "work-live") && row.groupedEntries.includes(answer), | |
| 2694 | ); | |
| 2695 | expect(answerRows).toMatchObject([ | |
| 2696 | { kind: "work", groupedEntries: [answer], isExpandedToolGroup: false }, | |
| 2697 | ]); | |
| 2698 | } | |
| 2699 | expect(active.find((row) => row.kind === "work-live")).toMatchObject({ | |
| 2700 | groupedEntries: tools.slice(2), | |
| 2701 | }); | |
| 2702 | }); | |
| 2703 | ||
| 2625 | 2704 | it("deduplicates integration sources and uses the first source icon for the group", () => { |
| 2626 | 2705 | const chromeSource = { |
| 2627 | 2706 | key: "browser-use:chrome", |
apps/web/src/components/chat/MessagesTimeline.logic.ts+11-1| ... | ... | @@ -657,6 +657,10 @@ function deriveTurnFolds(input: { |
| 657 | 657 | if (!isCompaction && index > terminalEntryIndex && !isSingleTrailingActivity) { |
| 658 | 658 | continue; |
| 659 | 659 | } |
| 660 | // User input stays visible after the surrounding work settles. | |
| 661 | if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) { | |
| 662 | continue; | |
| 663 | } | |
| 660 | 664 | // Agent-spawn CTA rows never fold: workflows outlive their launching |
| 661 | 665 | // turn (dynamic spawns, background execution), and folding the CTA |
| 662 | 666 | // when the turn settles makes a still-running fleet invisible. |
| ... | ... | @@ -919,6 +923,7 @@ export function deriveMessagesTimelineRows(input: { |
| 919 | 923 | !entryBelongsToActiveTurn(entry, index) || |
| 920 | 924 | entry.kind !== "work" || |
| 921 | 925 | entry.entry.agentSpawn !== undefined || |
| 926 | entry.entry.questionAnswer !== undefined || | |
| 922 | 927 | entry.entry.sourceActivityKind === "context-compaction" || |
| 923 | 928 | entry.entry.tone === "error" |
| 924 | 929 | ) { |
| ... | ... | @@ -1043,7 +1048,11 @@ export function deriveMessagesTimelineRows(input: { |
| 1043 | 1048 | } |
| 1044 | 1049 | |
| 1045 | 1050 | if (timelineEntry.kind === "work") { |
| 1046 | if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") { | |
| 1051 | if ( | |
| 1052 | timelineEntry.entry.agentSpawn !== undefined || | |
| 1053 | timelineEntry.entry.questionAnswer !== undefined || | |
| 1054 | timelineEntry.entry.tone === "error" | |
| 1055 | ) { | |
| 1047 | 1056 | nextRows.push({ |
| 1048 | 1057 | kind: "work", |
| 1049 | 1058 | id: timelineEntry.id, |
| ... | ... | @@ -1061,6 +1070,7 @@ export function deriveMessagesTimelineRows(input: { |
| 1061 | 1070 | !nextEntry || |
| 1062 | 1071 | nextEntry.kind !== "work" || |
| 1063 | 1072 | nextEntry.entry.agentSpawn !== undefined || |
| 1073 | nextEntry.entry.questionAnswer !== undefined || | |
| 1064 | 1074 | nextEntry.entry.sourceActivityKind === "context-compaction" || |
| 1065 | 1075 | nextEntry.entry.tone === "error" || |
| 1066 | 1076 | activeWorkEntryIds.has(nextEntry.id) || |
apps/web/src/components/chat/MessagesTimeline.test.tsx-2| ... | ... | @@ -348,8 +348,6 @@ describe("MessagesTimeline", () => { |
| 348 | 348 | />, |
| 349 | 349 | ); |
| 350 | 350 | }); |
| 351 | const toggle = renderer!.root.findByProps({ "aria-expanded": false }); | |
| 352 | await act(() => toggle.props.onClick()); | |
| 353 | 351 | const questionToggle = renderer!.root.find( |
| 354 | 352 | (node) => |
| 355 | 353 | node.props["aria-label"]?.startsWith("Question answer submitted:") && |