authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-17 14:15:48-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 14:15:48-03:00
logfbc8b960026e408c163ea55db8075bbf1a42d628
treec0db6b51bc7feabc0fb153e8d1a85a906a32399f
parentd4d5d12e8ba086cfbf79ca3adeb4156b46ead665
signature Signed by PGP key B5690EEEBB952194

fix(web): keep thoughts and failed tool calls in one activity row (#12270)


5 files changed, 151 insertions(+), 82 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+5-3
...@@ -2513,7 +2513,7 @@ describe("buildThreadFeed", () => {...@@ -2513,7 +2513,7 @@ describe("buildThreadFeed", () => {
2513 });2513 });
25142514
2515 it.each(["tool", "failed-tool", "assistant", "turn", "unknown-turn"] as const)(2515 it.each(["tool", "failed-tool", "assistant", "turn", "unknown-turn"] as const)(
2516 "preserves a %s boundary in expanded activity history",2516 "keeps thoughts in order across a %s in expanded activity history",
2517 (boundary) => {2517 (boundary) => {
2518 const turnId = TurnId.make("reasoning-boundary");2518 const turnId = TurnId.make("reasoning-boundary");
2519 const messages: OrchestrationThread["messages"] = [1, 3].map((second) => ({2519 const messages: OrchestrationThread["messages"] = [1, 3].map((second) => ({
...@@ -2576,8 +2576,10 @@ describe("buildThreadFeed", () => {...@@ -2576,8 +2576,10 @@ describe("buildThreadFeed", () => {
2576 (entry) => entry.type === "message" && entry.message.role === "reasoning",2576 (entry) => entry.type === "message" && entry.message.role === "reasoning",
2577 );2577 );
2578 if (boundary === "failed-tool") {2578 if (boundary === "failed-tool") {
2579 expect(rows.filter((entry) => entry.type === "work-toggle")).toHaveLength(3);2579 // A failed call stays inside the run instead of splitting it.
2580 expect(rows.some((entry) => entry.type === "work-toggle" && entry.hasFailure)).toBe(true);2580 expect(rows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
2581 { hasFailure: true, hiddenCount: 3 },
2582 ]);
2581 }2583 }
2582 expect(reasoningRows).toEqual(2584 expect(reasoningRows).toEqual(
2583 messages.map((message) => ({2585 messages.map((message) => ({
apps/mobile/src/lib/threadActivity.ts+1-4
...@@ -1901,10 +1901,7 @@ function activityRunTurnId(entry: ThreadFeedEntry): TurnId | null {...@@ -1901,10 +1901,7 @@ function activityRunTurnId(entry: ThreadFeedEntry): TurnId | null {
1901 !isContextCompactionActivityGroup(entry) &&1901 !isContextCompactionActivityGroup(entry) &&
1902 !isUserInputActivityGroup(entry) &&1902 !isUserInputActivityGroup(entry) &&
1903 entry.activities.every(1903 entry.activities.every(
1904 (activity) =>1904 (activity) => !activity.workEntry.agentSpawn && activity.workEntry.tone !== "error",
1905 !activity.workEntry.agentSpawn &&
1906 activity.workEntry.tone !== "error" &&
1907 !workEntryIndicatesToolFailure(activity.workEntry),
1908 )1905 )
1909 ) {1906 ) {
1910 return entry.turnId;1907 return entry.turnId;
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+49-15
...@@ -2137,7 +2137,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2137,7 +2137,7 @@ describe("deriveMessagesTimelineRows", () => {
2137 expect(rows[1]).toMatchObject({ entries: [thought] });2137 expect(rows[1]).toMatchObject({ entries: [thought] });
2138 });2138 });
21392139
2140 it("shows each tool once across expanded activity histories separated by a failed tool", () => {2140 it("keeps thoughts and tools in one activity row across a failed tool", () => {
2141 const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1");2141 const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1");
2142 const tools = ["a", "b", "c"].map((id, index) => {2142 const tools = ["a", "b", "c"].map((id, index) => {
2143 const entry = toolEntry(id, `2026-01-01T00:00:0${index + 2}Z`, "turn-1");2143 const entry = toolEntry(id, `2026-01-01T00:00:0${index + 2}Z`, "turn-1");
...@@ -2161,23 +2161,57 @@ describe("deriveMessagesTimelineRows", () => {...@@ -2161,23 +2161,57 @@ describe("deriveMessagesTimelineRows", () => {
2161 supportsConversationRollback: false,2161 supportsConversationRollback: false,
2162 } satisfies Parameters<typeof deriveMessagesTimelineRows>[0];2162 } satisfies Parameters<typeof deriveMessagesTimelineRows>[0];
2163 const rows = deriveMessagesTimelineRows(input);2163 const rows = deriveMessagesTimelineRows(input);
2164 const expanded = deriveMessagesTimelineRows({2164 expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group"]);
2165 expect(rows.at(-1)).toMatchObject({
2166 id: "live-activity-row",
2167 entries: [thought, ...tools],
2168 active: true,
2169 });
2170 const settled = deriveMessagesTimelineRows({
2165 ...input,2171 ...input,
2166 expandedWorkGroupIds: new Set(rows.flatMap((row) => ("groupId" in row ? [row.groupId] : []))),2172 timelineEntries: [
2167 });2173 thought,
2168 const visibleTools = expanded.flatMap((row) =>2174 ...tools,
2169 row.kind === "activity-group" && row.expanded2175 reasoningEntry("reasoning-next", "2026-01-01T00:00:05Z", "turn-1"),
2170 ? row.entries.flatMap((entry) => (entry.kind === "work" ? [entry.entry.id] : []))2176 { ...tools[1]!, id: "d", entry: { ...tools[1]!.entry, id: "d", toolCallId: "d" } },
2171 : row.kind === "work"2177 ],
2172 ? row.groupedEntries.map((entry) => entry.id)2178 isWorking: false,
2173 : [],2179 activeTurnStartedAt: null,
2174 );2180 });
2175 expect(visibleTools).toEqual(["a", "b", "c"]);2181 expect(settled.map((row) => row.kind)).toEqual(["activity-group"]);
2176 expect(expanded.filter((row) => row.id === "live-activity-row")).toMatchObject([
2177 { kind: "work-live", entry: { id: "c" } },
2178 ]);
2179 });2182 });
21802183
2184 it.each(["failed", "declined"] as const)(
2185 "settles the activity row while the latest tool is %s",
2186 (status) => {
2187 const thought = reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1");
2188 const tool = toolEntry("last-tool", "2026-01-01T00:00:02Z", "turn-1");
2189 const rows = deriveMessagesTimelineRows({
2190 timelineEntries: [
2191 thought,
2192 {
2193 ...tool,
2194 entry: {
2195 ...tool.entry,
2196 command: "echo nope",
2197 toolCallId: "last-tool",
2198 toolLifecycleStatus: status,
2199 sourceActivityKind: "tool.completed" as const,
2200 },
2201 },
2202 ],
2203 runningTurnId: TurnId.make("turn-1"),
2204 isWorking: true,
2205 activeTurnStartedAt: "2026-01-01T00:00:00Z",
2206 turnDiffSummaries: [],
2207 supportsConversationRollback: false,
2208 });
2209 expect(rows.map((row) => row.kind)).toEqual(["working", "activity-group", "thinking"]);
2210 expect(rows[1]).toMatchObject({ id: "activity-group:reasoning-entry", active: false });
2211 expect(rows[2]).toMatchObject({ id: "live-activity-row" });
2212 },
2213 );
2214
2181 it("folds mixed activity under worked-for and restores ordered details when expanded", () => {2215 it("folds mixed activity under worked-for and restores ordered details when expanded", () => {
2182 const entries = [2216 const entries = [
2183 reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"),2217 reasoningEntry("reasoning-entry", "2026-01-01T00:00:01Z", "turn-1"),
apps/web/src/components/chat/MessagesTimeline.logic.ts+5-5
...@@ -312,7 +312,7 @@ export type TimelineLatestTurn = Pick<...@@ -312,7 +312,7 @@ export type TimelineLatestTurn = Pick<
312 "turnId" | "state" | "startedAt" | "completedAt"312 "turnId" | "state" | "startedAt" | "completedAt"
313>;313>;
314314
315export const LIVE_ACTIVITY_ROW_ID = "live-activity-row";315const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
316316
317type ActivityEntry = Extract<TimelineEntry, { kind: "message" | "work" }>;317type ActivityEntry = Extract<TimelineEntry, { kind: "message" | "work" }>;
318318
...@@ -323,8 +323,7 @@ function isActivityEntry(entry: TimelineEntry): entry is ActivityEntry {...@@ -323,8 +323,7 @@ function isActivityEntry(entry: TimelineEntry): entry is ActivityEntry {
323 entry.entry.agentSpawn === undefined &&323 entry.entry.agentSpawn === undefined &&
324 entry.entry.questionAnswer === undefined &&324 entry.entry.questionAnswer === undefined &&
325 entry.entry.sourceActivityKind !== "context-compaction" &&325 entry.entry.sourceActivityKind !== "context-compaction" &&
326 entry.entry.tone !== "error" &&326 entry.entry.tone !== "error";
327 !workEntryDisplayIndicatesToolFailure(entry.entry);
328}327}
329328
330export type MessagesTimelineRow =329export type MessagesTimelineRow =
...@@ -391,7 +390,6 @@ export type MessagesTimelineRow =...@@ -391,7 +390,6 @@ export type MessagesTimelineRow =
391 createdAt: string;390 createdAt: string;
392 message: ChatMessage;391 message: ChatMessage;
393 durationStart: string;392 durationStart: string;
394 reasoningMessages?: ReadonlyArray<ChatMessage>;
395 showAssistantMeta: boolean;393 showAssistantMeta: boolean;
396 showAssistantCopyButton: boolean;394 showAssistantCopyButton: boolean;
397 assistantCopyStreaming: boolean;395 assistantCopyStreaming: boolean;
...@@ -1158,7 +1156,9 @@ export function deriveMessagesTimelineRows(input: {...@@ -1158,7 +1156,9 @@ export function deriveMessagesTimelineRows(input: {
1158 const active =1156 const active =
1159 input.isWorking &&1157 input.isWorking &&
1160 activityTurnId === unsettledTurnId &&1158 activityTurnId === unsettledTurnId &&
1161 cursor === input.timelineEntries.length;1159 cursor === input.timelineEntries.length &&
1160 !latestToolFailed &&
1161 (latestVisibleToolEntry === undefined || latestToolKeepsActivityLive);
1162 const groupId =1162 const groupId =
1163 timelineEntry.kind === "work"1163 timelineEntry.kind === "work"
1164 ? workGroupId(timelineEntry.id, timelineEntry.entry)1164 ? workGroupId(timelineEntry.id, timelineEntry.entry)
apps/web/src/components/chat/MessagesTimeline.tsx+91-55
...@@ -171,7 +171,6 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis...@@ -171,7 +171,6 @@ import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssis
171import {171import {
172 computeStableMessagesTimelineRows,172 computeStableMessagesTimelineRows,
173 deriveMessagesTimelineRowsWithState,173 deriveMessagesTimelineRowsWithState,
174 LIVE_ACTIVITY_ROW_ID,
175 deriveUnsettledTurnId,174 deriveUnsettledTurnId,
176 type MessagesTimelineRowsProjection,175 type MessagesTimelineRowsProjection,
177 liveWorkEntryLabel,176 liveWorkEntryLabel,
...@@ -2606,6 +2605,7 @@ function ActivityGroupTimelineRow({...@@ -2606,6 +2605,7 @@ function ActivityGroupTimelineRow({
2606 const liveWork = trailingWork.findLast(workEntryIsActiveTurnActivity) ?? trailingWork.at(-1);2605 const liveWork = trailingWork.findLast(workEntryIsActiveTurnActivity) ?? trailingWork.at(-1);
2607 const thinking = row.active && liveWork === undefined;2606 const thinking = row.active && liveWork === undefined;
2608 const iconWork = row.active ? liveWork : work.at(-1);2607 const iconWork = row.active ? liveWork : work.at(-1);
2608 const failed = iconWork !== undefined && workEntryDisplayIndicatesToolFailure(iconWork);
2609 const label = row.active2609 const label = row.active
2610 ? liveWork2610 ? liveWork
2611 ? liveWorkEntryLabel(liveWork, ctx.workspaceRoot, true)2611 ? liveWorkEntryLabel(liveWork, ctx.workspaceRoot, true)
...@@ -2639,20 +2639,11 @@ function ActivityGroupTimelineRow({...@@ -2639,20 +2639,11 @@ function ActivityGroupTimelineRow({
2639 if (next.kind === "message") messages.push(next.message);2639 if (next.kind === "message") messages.push(next.message);
2640 }2640 }
2641 details.push(2641 details.push(
2642 <ReasoningTimelineRow2642 <ReasoningTraceBlock
2643 key={entry.id}2643 key={entry.id}
2644 disclosureAnchorKey={row.id}2644 messages={messages}
2645 row={{2645 live={row.active && index === row.entries.length - 1}
2646 kind: "message",2646 showHeader={work.length > 0}
2647 id: row.active && index === row.entries.length - 1 ? LIVE_ACTIVITY_ROW_ID : entry.id,
2648 createdAt: entry.createdAt,
2649 message: entry.message,
2650 reasoningMessages: messages,
2651 durationStart: entry.createdAt,
2652 showAssistantMeta: false,
2653 showAssistantCopyButton: false,
2654 assistantCopyStreaming: false,
2655 }}
2656 />,2647 />,
2657 );2648 );
2658 }2649 }
...@@ -2663,6 +2654,7 @@ function ActivityGroupTimelineRow({...@@ -2663,6 +2654,7 @@ function ActivityGroupTimelineRow({
2663 <button2654 <button
2664 type="button"2655 type="button"
2665 className="group/live-work flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"2656 className="group/live-work flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
2657 aria-label={failed ? `${label}, tool call failed` : undefined}
2666 aria-expanded={row.expanded}2658 aria-expanded={row.expanded}
2667 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}2659 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
2668 >2660 >
...@@ -2670,6 +2662,7 @@ function ActivityGroupTimelineRow({...@@ -2670,6 +2662,7 @@ function ActivityGroupTimelineRow({
2670 label={label}2662 label={label}
2671 iconName={iconWork ? workEntryIconName(iconWork) : "brain"}2663 iconName={iconWork ? workEntryIconName(iconWork) : "brain"}
2672 toolIcon={iconWork?.toolIcon ?? iconWork?.toolSource?.icon}2664 toolIcon={iconWork?.toolIcon ?? iconWork?.toolSource?.icon}
2665 failed={failed}
2673 active={row.active}2666 active={row.active}
2674 shimmer={thinking}2667 shimmer={thinking}
2675 />2668 />
...@@ -2691,6 +2684,73 @@ function ThinkingTimelineRow() {...@@ -2691,6 +2684,73 @@ function ThinkingTimelineRow() {
2691 );2684 );
2692}2685}
26932686
2687/**
2688 * Thinking inside an expanded activity group: the trace is already one click
2689 * deep, so the text renders under its "Thought" header without another toggle.
2690 * A group whose row already reads "Thought" (no visible tool) skips the header.
2691 */
2692function ReasoningTraceBlock({
2693 messages,
2694 live,
2695 showHeader,
2696}: {
2697 messages: ReadonlyArray<ChatMessage>;
2698 live: boolean;
2699 showHeader: boolean;
2700}) {
2701 const ctx = use(TimelineRowCtx);
2702 const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx);
2703 const first = messages[0]!;
2704 const streaming =
2705 live &&
2706 messages.some((reasoningMessage) => reasoningMessage.streaming) &&
2707 isWorking &&
2708 first.turnId !== null &&
2709 first.turnId === unsettledTurnId;
2710 if (
2711 messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) &&
2712 !streaming
2713 ) {
2714 return null;
2715 }
2716 const label = streaming ? "Thinking" : "Thought";
2717 return (
2718 <div className="flex flex-col">
2719 {showHeader ? (
2720 <div className="flex min-h-6 select-none items-center gap-1.5 px-0.5 py-0.5 text-sm leading-relaxed">
2721 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
2722 <BrainIcon aria-hidden className="block size-4 shrink-0 stroke-[1.8] opacity-70" />
2723 </span>
2724 <span
2725 ref={streaming ? observeVisibleAnimation : undefined}
2726 className="relative min-w-0 flex-1 truncate text-secondary-label"
2727 >
2728 {label}
2729 {streaming ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
2730 </span>
2731 </div>
2732 ) : null}
2733 <div className="ms-7 flex max-h-96 flex-col gap-3 overflow-auto px-0.5 py-1 select-text">
2734 {messages.map((reasoningMessage) => (
2735 <ChatMarkdown
2736 key={reasoningMessage.id}
2737 className="text-foreground"
2738 text={reasoningMessage.text}
2739 cwd={ctx.markdownCwd}
2740 threadRef={ctx.threadRef ?? undefined}
2741 isStreaming={streaming && reasoningMessage.streaming}
2742 lineBreaks
2743 skills={ctx.skills}
2744 headingLevelOffset={MESSAGE_HEADING_LEVEL}
2745 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2746 onImageExpand={ctx.onImageExpand}
2747 />
2748 ))}
2749 </div>
2750 </div>
2751 );
2752}
2753
2694/**2754/**
2695 * A provider's thinking trace. Collapsed by default: reasoning is context for2755 * A provider's thinking trace. Collapsed by default: reasoning is context for
2696 * the answer, not the answer. The open/closed flag lives on the list so it2756 * the answer, not the answer. The open/closed flag lives on the list so it
...@@ -2698,35 +2758,18 @@ function ThinkingTimelineRow() {...@@ -2698,35 +2758,18 @@ function ThinkingTimelineRow() {
2698 */2758 */
2699const ReasoningTimelineRow = memo(function ReasoningTimelineRow({2759const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
2700 row,2760 row,
2701 disclosureAnchorKey = row.id,
2702}: {2761}: {
2703 row: Extract<TimelineRow, { kind: "message" }>;2762 row: Extract<TimelineRow, { kind: "message" }>;
2704 disclosureAnchorKey?: string;
2705}) {2763}) {
2706 const ctx = use(TimelineRowCtx);2764 const ctx = use(TimelineRowCtx);
2707 const { isWorking, unsettledTurnId } = use(TimelineRowActivityCtx);
2708 const { message } = row;2765 const { message } = row;
2709 const messages = row.reasoningMessages ?? [message];
2710 // A block left open by a crashed provider or a restarted server never gets
2711 // its completion. Only the live turn may claim to still be thinking, so a
2712 // settled turn cannot shimmer "Thinking" at the user forever.
2713 const streaming =
2714 row.id === LIVE_ACTIVITY_ROW_ID &&
2715 messages.some((reasoningMessage) => reasoningMessage.streaming) &&
2716 isWorking &&
2717 message.turnId !== null &&
2718 message.turnId === unsettledTurnId;
2719 const expanded = ctx.expandedReasoningMessageIds.has(message.id);2766 const expanded = ctx.expandedReasoningMessageIds.has(message.id);
2720 const { onToggleReasoning } = ctx;2767 const { onToggleReasoning } = ctx;
2721 const toggle = useCallback(() => {2768 const toggle = useCallback(() => {
2722 onToggleReasoning(message.id, !expanded, disclosureAnchorKey);2769 onToggleReasoning(message.id, !expanded, row.id);
2723 }, [expanded, message.id, disclosureAnchorKey, onToggleReasoning]);2770 }, [expanded, message.id, row.id, onToggleReasoning]);
2724 const label = `${streaming ? "Thinking" : "Thought"}${messages.length > 1 ? ` (×${messages.length})` : ""}`;
27252771
2726 if (2772 if (message.text.trim().length === 0) {
2727 messages.every((reasoningMessage) => reasoningMessage.text.trim().length === 0) &&
2728 !streaming
2729 ) {
2730 return null;2773 return null;
2731 }2774 }
27322775
...@@ -2742,12 +2785,8 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({...@@ -2742,12 +2785,8 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
2742 <BrainIcon aria-hidden className="block size-4 shrink-0 stroke-[1.8] opacity-70" />2785 <BrainIcon aria-hidden className="block size-4 shrink-0 stroke-[1.8] opacity-70" />
2743 </span>2786 </span>
2744 <span className="flex min-w-0 flex-1 items-center gap-1.5">2787 <span className="flex min-w-0 flex-1 items-center gap-1.5">
2745 <span2788 <span className="relative min-w-0 flex-1 truncate text-secondary-label text-sm leading-relaxed">
2746 ref={streaming ? observeVisibleAnimation : undefined}2789 Thought
2747 className="relative min-w-0 flex-1 truncate text-secondary-label text-sm leading-relaxed"
2748 >
2749 {label}
2750 {streaming ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
2751 </span>2790 </span>
2752 <span className="flex size-4 shrink-0 items-center justify-center" aria-hidden>2791 <span className="flex size-4 shrink-0 items-center justify-center" aria-hidden>
2753 <ChevronRightIcon2792 <ChevronRightIcon
...@@ -2760,21 +2799,18 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({...@@ -2760,21 +2799,18 @@ const ReasoningTimelineRow = memo(function ReasoningTimelineRow({
2760 </span>2799 </span>
2761 </button>2800 </button>
2762 {expanded ? (2801 {expanded ? (
2763 <div className="mt-1 ms-7 flex max-h-96 flex-col gap-3 overflow-auto rounded-md bg-muted/40 px-3 py-2 text-secondary-label select-text">2802 <div className="mt-1 ms-7 flex max-h-96 flex-col gap-3 overflow-auto px-0.5 py-1 select-text">
2764 {messages.map((reasoningMessage) => (2803 <ChatMarkdown
2765 <ChatMarkdown2804 className="text-foreground"
2766 key={reasoningMessage.id}2805 text={message.text}
2767 text={reasoningMessage.text}2806 cwd={ctx.markdownCwd}
2768 cwd={ctx.markdownCwd}2807 threadRef={ctx.threadRef ?? undefined}
2769 threadRef={ctx.threadRef ?? undefined}2808 lineBreaks
2770 isStreaming={streaming && reasoningMessage.streaming}2809 skills={ctx.skills}
2771 lineBreaks2810 headingLevelOffset={MESSAGE_HEADING_LEVEL}
2772 skills={ctx.skills}2811 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
2773 headingLevelOffset={MESSAGE_HEADING_LEVEL}2812 onImageExpand={ctx.onImageExpand}
2774 onUseArtifactTemplate={ctx.onUseArtifactTemplate}2813 />
2775 onImageExpand={ctx.onImageExpand}
2776 />
2777 ))}
2778 </div>2814 </div>
2779 ) : null}2815 ) : null}
2780 </div>2816 </div>