Skip to content

Commit f2ba2bb

Browse files
committed
feat(civilization): add operator UI and client runtime
1 parent 3b408af commit f2ba2bb

30 files changed

Lines changed: 10450 additions & 2 deletions

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2894,6 +2894,7 @@ export default function ChatView(props: ChatViewProps) {
28942894
attempts: serverProjection.attempts,
28952895
nodes: serverProjection.nodes,
28962896
plans: serverProjection.plans,
2897+
providerTurns: serverProjection.providerTurns,
28972898
}),
28982899
});
28992900
const draftTimelineEntries = optimisticUserMessages.map(

‎apps/web/src/components/chat/MessagesTimeline.logic.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4715,6 +4715,15 @@ describe("linked timeline resources", () => {
47154715
kind: "message" as const,
47164716
id: "final",
47174717
createdAt: "2026-09-08T10:00:04Z",
4718+
attempt: {
4719+
id: RunAttemptId.make("attempt-context-inspector-meta"),
4720+
runId,
4721+
attemptOrdinal: 1,
4722+
rootNodeId: NodeId.make("node-context-inspector-meta"),
4723+
status: "completed" as const,
4724+
completedAt: null,
4725+
},
4726+
providerTurnOrdinal: 4,
47184727
message: {
47194728
id: MessageId.make("final"),
47204729
role: "assistant" as const,
@@ -4736,6 +4745,10 @@ describe("linked timeline resources", () => {
47364745
expect(collapsed.find((row) => row.id === "summary:created")).toMatchObject({
47374746
resourceSummary: true,
47384747
});
4748+
expect(collapsed.find((row) => row.id === "assistant-meta:final")).toMatchObject({
4749+
attemptId: "attempt-context-inspector-meta",
4750+
providerTurnOrdinal: 4,
4751+
});
47394752
const expanded = deriveMessagesTimelineRows({
47404753
...common,
47414754
timelineEntries,

‎apps/web/src/components/chat/MessagesTimeline.logic.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,8 @@ type MessagesTimelineRowContent =
567567
showAssistantCopyButton: boolean;
568568
assistantCopyStreaming: boolean;
569569
assistantTurnDiffSummary?: TurnDiffSummary | undefined;
570+
attemptId?: RunAttemptId | undefined;
571+
providerTurnOrdinal?: number | undefined;
570572
revertTurnCount?: number | undefined;
571573
}
572574
| {
@@ -577,6 +579,8 @@ type MessagesTimelineRowContent =
577579
message: ChatMessage;
578580
showAssistantCopyButton: boolean;
579581
assistantCopyStreaming: boolean;
582+
attemptId?: RunAttemptId | undefined;
583+
providerTurnOrdinal?: number | undefined;
580584
}
581585
| {
582586
kind: "event";
@@ -1213,6 +1217,10 @@ function attachTrailingToolGroupsToAssistant(
12131217
message: row.message,
12141218
showAssistantCopyButton: row.showAssistantCopyButton,
12151219
assistantCopyStreaming: row.assistantCopyStreaming,
1220+
...(row.attemptId === undefined ? {} : { attemptId: row.attemptId }),
1221+
...(row.providerTurnOrdinal === undefined
1222+
? {}
1223+
: { providerTurnOrdinal: row.providerTurnOrdinal }),
12161224
});
12171225
}
12181226

@@ -1804,6 +1812,10 @@ export function deriveMessagesTimelineRows(input: {
18041812
showAssistantMeta,
18051813
showAssistantCopyButton: showAssistantMeta,
18061814
assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress,
1815+
...(timelineEntry.attempt === undefined ? {} : { attemptId: timelineEntry.attempt.id }),
1816+
...(timelineEntry.providerTurnOrdinal === undefined
1817+
? {}
1818+
: { providerTurnOrdinal: timelineEntry.providerTurnOrdinal }),
18071819
assistantTurnDiffSummary:
18081820
timelineEntry.message.role === "assistant"
18091821
? turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id)

‎apps/web/src/components/chat/MessagesTimeline.tsx‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,8 @@ import { McpAppFrame } from "./McpAppFrame";
178178
import { ChangedFilesCard } from "./ChangedFilesTree";
179179
import { useFileContextMenuHandler } from "../../fileContextMenu";
180180
import { useProject, useThreadShell } from "../../state/entities";
181+
import { useSettingsProjectGroups } from "../settings/useSettingsProjectGroups";
182+
import { resolveCivilizationContextSettingsLink } from "./civilizationContextInspectorLink";
181183
import {
182184
CHAT_TIMELINE_ANCHOR_OFFSET,
183185
readTimelinePosition,
@@ -2619,6 +2621,8 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
26192621
className="mt-1.5"
26202622
projectedItem={row.projectedItem}
26212623
message={row.message}
2624+
attemptId={row.attemptId}
2625+
providerTurnOrdinal={row.providerTurnOrdinal}
26222626
showCopyButton={row.showAssistantCopyButton}
26232627
copyStreaming={row.assistantCopyStreaming}
26242628
/>
@@ -2685,6 +2689,8 @@ function AssistantMetaTimelineRow({
26852689
className="mt-0.5"
26862690
projectedItem={row.projectedItem}
26872691
message={row.message}
2692+
attemptId={row.attemptId}
2693+
providerTurnOrdinal={row.providerTurnOrdinal}
26882694
showCopyButton={row.showAssistantCopyButton}
26892695
copyStreaming={row.assistantCopyStreaming}
26902696
alwaysVisible
@@ -2697,13 +2703,17 @@ function AssistantMessageMeta({
26972703
className,
26982704
projectedItem,
26992705
message,
2706+
attemptId,
2707+
providerTurnOrdinal,
27002708
showCopyButton,
27012709
copyStreaming,
27022710
alwaysVisible = false,
27032711
}: {
27042712
className?: string;
27052713
projectedItem?: Extract<TimelineRow, { kind: "message" }>["projectedItem"];
27062714
message: ChatMessage;
2715+
attemptId?: RunAttemptId | undefined;
2716+
providerTurnOrdinal?: number | undefined;
27072717
showCopyButton: boolean;
27082718
copyStreaming: boolean;
27092719
alwaysVisible?: boolean;
@@ -2733,6 +2743,12 @@ function AssistantMessageMeta({
27332743
{projectedItem?.item.type === "assistant_message" ? (
27342744
<AssistantForkButton projectedItem={projectedItem} />
27352745
) : null}
2746+
<CivilizationContextInspectionLink
2747+
projectedItem={projectedItem}
2748+
message={message}
2749+
attemptId={attemptId}
2750+
providerTurnOrdinal={providerTurnOrdinal}
2751+
/>
27362752
{!message.streaming && (
27372753
<Tooltip>
27382754
<TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}>
@@ -2747,6 +2763,67 @@ function AssistantMessageMeta({
27472763
);
27482764
}
27492765

2766+
function CivilizationContextInspectionLink({
2767+
projectedItem,
2768+
message,
2769+
attemptId,
2770+
providerTurnOrdinal,
2771+
}: {
2772+
readonly projectedItem?: Extract<TimelineRow, { kind: "message" }>["projectedItem"];
2773+
readonly message: ChatMessage;
2774+
readonly attemptId?: RunAttemptId | undefined;
2775+
readonly providerTurnOrdinal?: number | undefined;
2776+
}) {
2777+
const ctx = use(TimelineRowCtx);
2778+
const sourceThreadId = projectedItem?.sourceThreadId ?? null;
2779+
const sourceThread = useThreadShell(
2780+
sourceThreadId === null ? null : scopeThreadRef(ctx.activeThreadEnvironmentId, sourceThreadId),
2781+
);
2782+
const sourceProjectId = sourceThread?.projectId ?? null;
2783+
const projectGroups = useSettingsProjectGroups();
2784+
const search = (() => {
2785+
if (
2786+
sourceThreadId === null ||
2787+
sourceProjectId === null ||
2788+
message.runId === null ||
2789+
attemptId === undefined ||
2790+
providerTurnOrdinal === undefined
2791+
) {
2792+
return null;
2793+
}
2794+
return resolveCivilizationContextSettingsLink({
2795+
groups: projectGroups,
2796+
environmentId: ctx.activeThreadEnvironmentId,
2797+
projectId: sourceProjectId,
2798+
threadId: sourceThreadId,
2799+
runId: message.runId,
2800+
attemptId,
2801+
providerTurnOrdinal,
2802+
});
2803+
})();
2804+
2805+
if (search === null) return null;
2806+
2807+
return (
2808+
<Tooltip>
2809+
<TooltipTrigger
2810+
render={
2811+
<Link
2812+
to="/settings/civilization"
2813+
search={search}
2814+
hash="civilization-context-inspector"
2815+
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
2816+
aria-label="Inspect Civilization context for this turn"
2817+
>
2818+
<EyeIcon className="size-3.5" aria-hidden="true" />
2819+
</Link>
2820+
}
2821+
/>
2822+
<TooltipPopup>Inspect Civilization context for this provider turn</TooltipPopup>
2823+
</Tooltip>
2824+
);
2825+
}
2826+
27502827
function AssistantCopyButton({
27512828
message,
27522829
showCopyButton,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { EnvironmentId, ProjectId, RunAttemptId, RunId, ThreadId } from "@t3tools/contracts";
2+
import { describe, expect, it } from "vite-plus/test";
3+
4+
import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
5+
import { resolveCivilizationContextSettingsLink } from "./civilizationContextInspectorLink";
6+
7+
const environmentId = EnvironmentId.make("environment-civilization-link");
8+
const projectId = ProjectId.make("project-civilization-link");
9+
const threadId = ThreadId.make("thread-civilization-link");
10+
const runId = RunId.make("run-civilization-link");
11+
const attemptId = RunAttemptId.make("attempt-civilization-link");
12+
const group = {
13+
projectKey: "logical-project-key",
14+
memberProjects: [
15+
{
16+
environmentId,
17+
id: projectId,
18+
physicalProjectKey: `${environmentId}:/repo`,
19+
},
20+
],
21+
} as unknown as SidebarProjectSnapshot;
22+
23+
describe("Civilization context Settings link", () => {
24+
it("resolves the exact physical project and historical provider-turn identity", () => {
25+
expect(
26+
resolveCivilizationContextSettingsLink({
27+
groups: [group],
28+
environmentId,
29+
projectId,
30+
threadId,
31+
runId,
32+
attemptId,
33+
providerTurnOrdinal: 3,
34+
}),
35+
).toEqual({
36+
project: "logical-project-key",
37+
machine: environmentId,
38+
checkout: `${environmentId}:/repo`,
39+
contextThread: threadId,
40+
contextRun: runId,
41+
contextAttempt: attemptId,
42+
contextTurn: 3,
43+
});
44+
});
45+
46+
it("does not broaden an unknown project or invalid provider turn", () => {
47+
expect(
48+
resolveCivilizationContextSettingsLink({
49+
groups: [group],
50+
environmentId,
51+
projectId: ProjectId.make("missing-project"),
52+
threadId,
53+
runId,
54+
attemptId,
55+
providerTurnOrdinal: 3,
56+
}),
57+
).toBeNull();
58+
expect(
59+
resolveCivilizationContextSettingsLink({
60+
groups: [group],
61+
environmentId,
62+
projectId,
63+
threadId,
64+
runId,
65+
attemptId,
66+
providerTurnOrdinal: 0,
67+
}),
68+
).toBeNull();
69+
});
70+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { EnvironmentId, ProjectId, RunAttemptId, RunId, ThreadId } from "@t3tools/contracts";
2+
3+
import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
4+
5+
export interface CivilizationContextSettingsLinkSearch {
6+
readonly project: string;
7+
readonly machine: EnvironmentId;
8+
readonly checkout: string;
9+
readonly contextThread: ThreadId;
10+
readonly contextRun: RunId;
11+
readonly contextAttempt: RunAttemptId;
12+
readonly contextTurn: number;
13+
}
14+
15+
/** Exact physical Settings target for one historical provider-turn inspection. */
16+
export function resolveCivilizationContextSettingsLink(input: {
17+
readonly groups: ReadonlyArray<SidebarProjectSnapshot>;
18+
readonly environmentId: EnvironmentId;
19+
readonly projectId: ProjectId;
20+
readonly threadId: ThreadId;
21+
readonly runId: RunId;
22+
readonly attemptId: RunAttemptId;
23+
readonly providerTurnOrdinal: number;
24+
}): CivilizationContextSettingsLinkSearch | null {
25+
if (!Number.isSafeInteger(input.providerTurnOrdinal) || input.providerTurnOrdinal < 1)
26+
return null;
27+
for (const group of input.groups) {
28+
const member = group.memberProjects.find(
29+
(candidate) =>
30+
candidate.environmentId === input.environmentId && candidate.id === input.projectId,
31+
);
32+
if (member === undefined) continue;
33+
return {
34+
project: group.projectKey,
35+
machine: input.environmentId,
36+
checkout: member.physicalProjectKey,
37+
contextThread: input.threadId,
38+
contextRun: input.runId,
39+
contextAttempt: input.attemptId,
40+
contextTurn: input.providerTurnOrdinal,
41+
};
42+
}
43+
return null;
44+
}

0 commit comments

Comments
 (0)