Skip to content

Commit 840b865

Browse files
committed
Release 1.0.9: Timeline aligned with Grok Build (session events + edit diffs)
1 parent 1605c08 commit 840b865

14 files changed

Lines changed: 1393 additions & 252 deletions

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pinkcode",
3-
"version": "1.0.8",
3+
"version": "1.0.9",
44
"description": "Desktop mission control for Grok agents.",
55
"type": "module",
66
"keywords": [

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pinkcode"
3-
version = "1.0.8"
3+
version = "1.0.9"
44
description = "Desktop control plane for Grok Build multi-task observability"
55
authors = ["PinkCode"]
66
license = "Apache-2.0"

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "PinkCode",
44
"mainBinaryName": "PinkCode",
5-
"version": "1.0.8",
5+
"version": "1.0.9",
66
"identifier": "com.pinkcode.app",
77
"build": {
88
"beforeDevCommand": "npm run dev",

src/hooks/liveTimeline.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
reduceShellUpdate,
1212
settleLifecycleItems,
1313
settleStreamingItems,
14+
type UpdateDescription,
1415
} from "./liveTimeline";
1516
import type { TimelineItem } from "../types";
1617

@@ -267,6 +268,43 @@ describe("live timeline reducer", () => {
267268
expect(terminal?.detail).toBe("1.2k tok");
268269
});
269270

271+
it("emits the goal-complete milestone once per goal id", () => {
272+
const shells = createTimelineReducerState();
273+
let map = new Map<string, TimelineItem[]>();
274+
const complete: UpdateDescription = {
275+
kind: "event",
276+
title: "Goal complete — 5m end-to-end.",
277+
goalId: "g1",
278+
};
279+
map = reduceAgentUpdate(
280+
map,
281+
{ handleId: "h1", sessionId: "sess-1", description: complete, now: 1, nextId: () => "g1" },
282+
shells,
283+
);
284+
expect(map.get("sess-1")?.length).toBe(1);
285+
// A repeated complete broadcast (replay / leader re-emit) must not stack.
286+
const same = reduceAgentUpdate(
287+
map,
288+
{ handleId: "h1", sessionId: "sess-1", description: complete, now: 2, nextId: () => "g1b" },
289+
shells,
290+
);
291+
expect(same).toBe(map);
292+
expect(same.get("sess-1")?.length).toBe(1);
293+
// A different goal still renders.
294+
const other = reduceAgentUpdate(
295+
map,
296+
{
297+
handleId: "h1",
298+
sessionId: "sess-1",
299+
description: { ...complete, goalId: "g2" },
300+
now: 3,
301+
nextId: () => "g2",
302+
},
303+
shells,
304+
);
305+
expect(other.get("sess-1")?.length).toBe(2);
306+
});
307+
270308
it("timeline elapsed always owns turn_completed title", () => {
271309
const shells = createTimelineReducerState();
272310
let map = new Map<string, TimelineItem[]>();

src/hooks/liveTimeline.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export type ShellIndexes = Map<string, Map<string, number>>;
2626
export interface TimelineReducerState {
2727
shellIndexes: ShellIndexes;
2828
suppressedToolIds: Map<string, Set<string>>;
29+
/** Goal ids that already emitted their "Goal complete" milestone (Grok just_completed). */
30+
completedGoalIds: Set<string>;
2931
}
3032
export type TimelineRetention = "live" | "history";
3133

@@ -39,6 +41,7 @@ export function createTimelineReducerState(): TimelineReducerState {
3941
return {
4042
shellIndexes: new Map(),
4143
suppressedToolIds: new Map(),
44+
completedGoalIds: new Set(),
4245
};
4346
}
4447

@@ -288,6 +291,15 @@ export function reduceAgentUpdate(
288291
input.streaming !== undefined ? input.streaming : textUpdate ? true : undefined;
289292
let listChanged = false;
290293

294+
// Emit the "Goal complete" milestone once per goal (Grok Build gates on the
295+
// just-completed transition; a repeated complete broadcast must not stack).
296+
if (description.kind === "event" && description.goalId) {
297+
if (reducerState.completedGoalIds.has(description.goalId)) {
298+
return previous;
299+
}
300+
reducerState.completedGoalIds.add(description.goalId);
301+
}
302+
291303
if (!textUpdate) {
292304
for (let index = 0; index < list.length; index++) {
293305
if (list[index].streaming && list[index].handleId === handleId) {

src/hooks/useAgentEvents.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -477,8 +477,9 @@ export function useAgentEvents(
477477
return;
478478
}
479479

480-
// Non-lifecycle notifications (e.g. FileWritten for modified files) are parsed centrally in describeUpdate.
481-
// This removes duplicated handling and lets the parser own all notification shapes.
480+
// Non-lifecycle notifications (x.ai session events, subagent/task
481+
// lifecycle) are parsed centrally in describeUpdate — one parser owns
482+
// all notification shapes.
482483
const desc = describeUpdate({
483484
method,
484485
params: params as any,

src/utils/editDiff.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { describe, expect, it } from "vitest";
2+
import { extractEditDiff, isEditToolUpdate } from "./editDiff";
3+
4+
describe("isEditToolUpdate", () => {
5+
it("detects Edit kind and edit-class tool names", () => {
6+
// ACP ToolKind serializes lowercase on the wire ("kind":"edit").
7+
expect(isEditToolUpdate({ kind: "edit", title: "Edit `src/a.ts`" })).toBe(true);
8+
expect(isEditToolUpdate({ kind: "Edit", title: "Edit `src/a.ts`" })).toBe(true);
9+
expect(isEditToolUpdate({ kind: "read", title: "Read `src/a.ts`" })).toBe(false);
10+
expect(
11+
isEditToolUpdate({
12+
kind: "Other",
13+
_meta: { "x.ai/tool": { name: "search_replace" } },
14+
}),
15+
).toBe(true);
16+
expect(
17+
isEditToolUpdate({
18+
kind: "Other",
19+
_meta: { "x.ai/tool": { name: "apply_patch" } },
20+
}),
21+
).toBe(true);
22+
expect(
23+
isEditToolUpdate({
24+
kind: "Other",
25+
_meta: { "x.ai/tool": { name: "run_terminal_command" } },
26+
}),
27+
).toBe(false);
28+
});
29+
});
30+
31+
describe("extractEditDiff", () => {
32+
it("builds a unified diff from SearchReplace rawOutput details", () => {
33+
const diff = extractEditDiff({
34+
kind: "Edit",
35+
title: "Edit `src/main.ts`",
36+
rawOutput: {
37+
type: "SearchReplace",
38+
EditsApplied: {
39+
old_string: "let x = 1;",
40+
new_string: "let x = 2;",
41+
absolute_path: "/tmp/main.ts",
42+
edits: {
43+
details: [
44+
{
45+
old_string: "let x = 1;",
46+
new_string: "let x = 2;",
47+
old_line: 5,
48+
new_line: 5,
49+
context_before: "fn main() {\n",
50+
context_after: "}",
51+
line_prefix: "",
52+
},
53+
],
54+
},
55+
},
56+
},
57+
});
58+
// Hunk starts at the first shown old line — the context line at 4.
59+
expect(diff).toContain("@@ -4,3 +4,3 @@");
60+
expect(diff).toContain("-let x = 1;");
61+
expect(diff).toContain("+let x = 2;");
62+
expect(diff).toContain(" fn main() {");
63+
});
64+
65+
it("accepts snake_case raw_output too (disk hydrate tolerance)", () => {
66+
const diff = extractEditDiff({
67+
kind: "Edit",
68+
raw_output: {
69+
type: "SearchReplace",
70+
EditsApplied: {
71+
edits: {
72+
details: [
73+
{
74+
old_string: "a",
75+
new_string: "b",
76+
old_line: 1,
77+
new_line: 1,
78+
},
79+
],
80+
},
81+
},
82+
},
83+
});
84+
expect(diff).toContain("-a");
85+
expect(diff).toContain("+b");
86+
});
87+
88+
it("falls back to content diff blocks (write tool full content)", () => {
89+
const diff = extractEditDiff({
90+
kind: "Edit",
91+
title: "Write `src/out.txt`",
92+
content: [
93+
{
94+
type: "diff",
95+
path: "src/out.txt",
96+
new_text: "hello\nworld\n",
97+
},
98+
],
99+
});
100+
// Pure insertion: zero old lines ("-1,0"), two new lines.
101+
expect(diff).toContain("@@ -1,0 +1,2 @@");
102+
expect(diff).toContain("+hello");
103+
expect(diff).toContain("+world");
104+
});
105+
106+
it("renders replacements against the old text via LCS alignment", () => {
107+
const diff = extractEditDiff({
108+
kind: "Edit",
109+
content: [
110+
{
111+
type: "diff",
112+
path: "x.txt",
113+
old_text: "keep\nremove\nkeep2\n",
114+
new_text: "keep\nadded\nkeep2\n",
115+
},
116+
],
117+
});
118+
expect(diff).toContain("-remove");
119+
expect(diff).toContain("+added");
120+
expect(diff).toContain(" keep");
121+
});
122+
123+
it("returns undefined for non-edit output", () => {
124+
expect(
125+
extractEditDiff({ kind: "Read", rawOutput: { type: "ReadFile" } }),
126+
).toBeUndefined();
127+
expect(
128+
extractEditDiff({
129+
kind: "Edit",
130+
rawOutput: { type: "Bash", output: [], exit_code: 0 },
131+
}),
132+
).toBeUndefined();
133+
});
134+
});

0 commit comments

Comments
 (0)