Skip to content

Commit b2c261c

Browse files
authored
Merge pull request #119 from 7df-lab/dev/desktop_0626
feat: add workspace changes views
2 parents ecff212 + 57321af commit b2c261c

61 files changed

Lines changed: 3736 additions & 177 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

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

apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ class FakeTransport implements DevoAcpTransport {
4848
}
4949
}
5050

51+
emitNotification(method: string, params: unknown): void {
52+
for (const listener of this.listeners) {
53+
listener({ type: "notification", method, params })
54+
}
55+
}
56+
5157
emitRequest(id: string | number, method: string, params: unknown): void {
5258
for (const listener of this.listeners) {
5359
listener({ type: "request", id, method, params })
@@ -82,6 +88,36 @@ const initializeResult = {
8288
authMethods: [],
8389
}
8490

91+
const workspaceChangeView = {
92+
scope: "turn",
93+
status: "ready",
94+
workspace_root: "/repo",
95+
base: {
96+
kind: "turn_checkpoint",
97+
turn_id: "t1",
98+
checkpoint_id: "checkpoint-1",
99+
backend: "git_ghost_commit",
100+
},
101+
coverage: "git_visible",
102+
attribution: "workspace_net",
103+
change_set_status: "finalized",
104+
files: [
105+
{
106+
path: "src/main.rs",
107+
status: "modified",
108+
additions: 2,
109+
deletions: 1,
110+
binary: false,
111+
diff_truncated: false,
112+
},
113+
],
114+
stats: { files_changed: 1, additions: 2, deletions: 1 },
115+
unified_diff:
116+
"diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new\n",
117+
warnings: [],
118+
generated_at: "2026-06-26T00:00:00Z",
119+
}
120+
85121
const originalNow = Date.now
86122

87123
afterEach(() => {
@@ -1029,6 +1065,119 @@ describe("ACP desktop SDK session mapping", () => {
10291065
])
10301066
})
10311067

1068+
test("reads workspace changes through the runtime workspace API", async () => {
1069+
const transport = new FakeTransport((method, params, directory) => {
1070+
if (method === "_devo/workspace/changes/read") {
1071+
expect(directory).toBe("/repo")
1072+
expect(params).toEqual({
1073+
session_id: "s1",
1074+
scopes: ["turn"],
1075+
diff_detail: "full",
1076+
turn_id: "t1",
1077+
max_diff_bytes: 2_000_000,
1078+
})
1079+
return { views: [workspaceChangeView] }
1080+
}
1081+
throw new Error(`unexpected request ${method}`)
1082+
})
1083+
const client = createDevoClient({ directory: "/repo", transport })
1084+
1085+
const result = await client.workspace.changes.read({
1086+
sessionID: "s1",
1087+
scopes: ["turn"],
1088+
turnID: "t1",
1089+
diffDetail: "full",
1090+
maxDiffBytes: 2_000_000,
1091+
})
1092+
1093+
expect(result.data).toEqual({ views: [workspaceChangeView] })
1094+
})
1095+
1096+
test("emits workspace change events from direct workspace notifications", async () => {
1097+
const transport = new FakeTransport((method) => {
1098+
if (method === "initialize") return initializeResult
1099+
throw new Error(`unexpected request ${method}`)
1100+
})
1101+
const client = createDevoClient({ directory: "/repo", transport })
1102+
const stream = (await client.global.event()).stream[Symbol.asyncIterator]()
1103+
1104+
transport.emitNotification("workspace/changes/updated", {
1105+
session_id: "s1",
1106+
turn_id: "t1",
1107+
scope: "turn",
1108+
status: "ready",
1109+
coverage: "git_visible",
1110+
change_set_status: "finalized",
1111+
stats: { files_changed: 1, additions: 2, deletions: 1 },
1112+
version: 42,
1113+
generated_at: "2026-06-26T00:00:00Z",
1114+
})
1115+
1116+
expect(await nextPayload(stream, "workspace-direct")).toEqual({
1117+
type: "workspace.changes.updated",
1118+
properties: {
1119+
sessionID: "s1",
1120+
turnID: "t1",
1121+
scope: "turn",
1122+
status: "ready",
1123+
coverage: "git_visible",
1124+
changeSetStatus: "finalized",
1125+
stats: { filesChanged: 1, additions: 2, deletions: 1 },
1126+
version: 42,
1127+
generatedAt: "2026-06-26T00:00:00Z",
1128+
},
1129+
})
1130+
})
1131+
1132+
test("emits workspace change events from wrapped original server events", async () => {
1133+
const transport = new FakeTransport((method) => {
1134+
if (method === "initialize") return initializeResult
1135+
if (method === "session/list") return { sessions: [sessionInfo] }
1136+
throw new Error(`unexpected request ${method}`)
1137+
})
1138+
const client = createDevoClient({ directory: "/repo", transport })
1139+
const stream = (await client.global.event()).stream[Symbol.asyncIterator]()
1140+
1141+
await client.session.list()
1142+
transport.emitSessionUpdate({
1143+
sessionId: "s1",
1144+
update: { sessionUpdate: "session_info_update" },
1145+
_meta: {
1146+
"devo/originalEvent": {
1147+
kind: "workspace_changes_updated",
1148+
session_id: "s1",
1149+
turn_id: "t1",
1150+
scope: "turn",
1151+
status: "ready",
1152+
coverage: "git_visible",
1153+
change_set_status: "finalized",
1154+
stats: { files_changed: 1, additions: 2, deletions: 1 },
1155+
version: 43,
1156+
generated_at: "2026-06-26T00:00:01Z",
1157+
},
1158+
},
1159+
} satisfies AcpSessionNotification)
1160+
1161+
expect(await nextPayload(stream, "workspace-session-update")).toEqual({
1162+
type: "session.updated",
1163+
properties: { info: expect.any(Object), session: expect.any(Object) },
1164+
})
1165+
expect(await nextPayload(stream, "workspace-wrapped")).toEqual({
1166+
type: "workspace.changes.updated",
1167+
properties: {
1168+
sessionID: "s1",
1169+
turnID: "t1",
1170+
scope: "turn",
1171+
status: "ready",
1172+
coverage: "git_visible",
1173+
changeSetStatus: "finalized",
1174+
stats: { filesChanged: 1, additions: 2, deletions: 1 },
1175+
version: 43,
1176+
generatedAt: "2026-06-26T00:00:01Z",
1177+
},
1178+
})
1179+
})
1180+
10321181
test("maps original request_user_input events to questions and replies through runtime API", async () => {
10331182
const transport = new FakeTransport((method) => {
10341183
if (method === "initialize") return initializeResult

apps/desktop/packages/devo-ai-sdk/src/v2/client.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ import type {
3434
ModelConfigParams,
3535
ModelConfigResult,
3636
RequestUserInputRespondParams,
37+
WorkspaceChangeCoverage,
38+
WorkspaceChangeScope,
39+
WorkspaceChangeSetStatus,
40+
WorkspaceChangeStats,
41+
WorkspaceChangeViewStatus,
42+
WorkspaceChangesReadParams,
43+
WorkspaceChangesReadResult,
44+
WorkspaceChangesUpdatedPayload,
45+
WorkspaceDiffDetail,
3746
} from "./generated/protocol"
3847
import {
3948
ProtocolValidationError,
@@ -122,6 +131,48 @@ export type ToolState = any
122131
export type ToolStateCompleted = any
123132
export type UserMessage = any
124133
export type Worktree = any
134+
export type {
135+
WorkspaceChangeAttribution,
136+
WorkspaceChangeBase,
137+
WorkspaceChangeCoverage,
138+
WorkspaceChangeScope,
139+
WorkspaceChangeSetStatus,
140+
WorkspaceChangeStats,
141+
WorkspaceChangeView,
142+
WorkspaceChangeViewStatus,
143+
WorkspaceChangedFile,
144+
WorkspaceChangedFileStatus,
145+
WorkspaceChangesReadParams,
146+
WorkspaceChangesReadResult,
147+
WorkspaceChangesUpdatedPayload,
148+
WorkspaceDiffDetail,
149+
} from "./generated/protocol"
150+
151+
export type WorkspaceChangesReadOptions = {
152+
sessionID: string
153+
cwd?: string
154+
scopes: WorkspaceChangeScope[]
155+
baseBranch?: string
156+
turnID?: string
157+
diffDetail?: WorkspaceDiffDetail
158+
maxDiffBytes?: number | bigint
159+
}
160+
161+
export type WorkspaceChangesUpdatedEventProperties = {
162+
sessionID: string
163+
turnID: string
164+
scope: WorkspaceChangeScope
165+
status: WorkspaceChangeViewStatus
166+
coverage: WorkspaceChangeCoverage
167+
changeSetStatus: WorkspaceChangeSetStatus
168+
stats: {
169+
filesChanged: number
170+
additions: number
171+
deletions: number
172+
}
173+
version: number
174+
generatedAt: string
175+
}
125176

126177
interface GlobalEvent {
127178
directory: string
@@ -153,6 +204,71 @@ function sessionMeta(value: unknown): Record<string, unknown> | undefined {
153204
return objectRecord(meta?.["devo/session"])
154205
}
155206

207+
function numberFromProtocol(value: unknown): number {
208+
if (typeof value === "number" && Number.isFinite(value)) return value
209+
if (typeof value === "bigint") return Number(value)
210+
if (typeof value === "string") {
211+
const parsed = Number(value)
212+
if (Number.isFinite(parsed)) return parsed
213+
}
214+
return 0
215+
}
216+
217+
function workspaceChangeStats(value: unknown): WorkspaceChangeStats {
218+
const stats = objectRecord(value)
219+
return {
220+
files_changed: numberFromProtocol(stats?.files_changed ?? stats?.filesChanged),
221+
additions: numberFromProtocol(stats?.additions),
222+
deletions: numberFromProtocol(stats?.deletions),
223+
}
224+
}
225+
226+
function workspaceChangesUpdatedFromOriginalEvent(
227+
original: unknown,
228+
): WorkspaceChangesUpdatedPayload | null {
229+
const event = objectRecord(original)
230+
if (!event) return null
231+
const payload =
232+
event.kind === "workspace_changes_updated"
233+
? event
234+
: objectRecord(event.WorkspaceChangesUpdated) ??
235+
objectRecord(event.workspace_changes_updated)
236+
if (!payload) return null
237+
return {
238+
session_id: String(payload.session_id ?? payload.sessionId ?? ""),
239+
turn_id: String(payload.turn_id ?? payload.turnId ?? ""),
240+
scope: String(payload.scope ?? "turn") as WorkspaceChangeScope,
241+
status: String(payload.status ?? "ready") as WorkspaceChangeViewStatus,
242+
coverage: String(payload.coverage ?? "none") as WorkspaceChangeCoverage,
243+
change_set_status: String(
244+
payload.change_set_status ?? payload.changeSetStatus ?? "finalized",
245+
) as WorkspaceChangeSetStatus,
246+
stats: workspaceChangeStats(payload.stats),
247+
version: numberFromProtocol(payload.version),
248+
generated_at: String(payload.generated_at ?? payload.generatedAt ?? ""),
249+
}
250+
}
251+
252+
function workspaceChangesUpdatedEventProperties(
253+
payload: WorkspaceChangesUpdatedPayload,
254+
): WorkspaceChangesUpdatedEventProperties {
255+
return {
256+
sessionID: payload.session_id,
257+
turnID: payload.turn_id,
258+
scope: payload.scope,
259+
status: payload.status,
260+
coverage: payload.coverage,
261+
changeSetStatus: payload.change_set_status,
262+
stats: {
263+
filesChanged: numberFromProtocol(payload.stats.files_changed),
264+
additions: numberFromProtocol(payload.stats.additions),
265+
deletions: numberFromProtocol(payload.stats.deletions),
266+
},
267+
version: numberFromProtocol(payload.version),
268+
generatedAt: payload.generated_at,
269+
}
270+
}
271+
156272
function parseTimestampMs(value: unknown): number | undefined {
157273
if (typeof value !== "string") return undefined
158274
const parsed = Date.parse(value)
@@ -441,6 +557,29 @@ class AcpClient {
441557
},
442558
}
443559

560+
workspace = {
561+
changes: {
562+
read: async (params: WorkspaceChangesReadOptions) => {
563+
const wireParams: WorkspaceChangesReadParams = {
564+
session_id: params.sessionID,
565+
scopes: params.scopes,
566+
diff_detail: params.diffDetail ?? "summary",
567+
}
568+
if (params.cwd !== undefined) wireParams.cwd = params.cwd
569+
if (params.baseBranch !== undefined) wireParams.base_branch = params.baseBranch
570+
if (params.turnID !== undefined) wireParams.turn_id = params.turnID
571+
if (params.maxDiffBytes !== undefined) {
572+
wireParams.max_diff_bytes = Number(params.maxDiffBytes)
573+
}
574+
const data = (await this.request(
575+
"_devo/workspace/changes/read",
576+
wireParams,
577+
)) as WorkspaceChangesReadResult
578+
return { data }
579+
},
580+
},
581+
}
582+
444583
command = {
445584
list: async () => ({ data: [{ name: "compact", description: "Compact the session" }] }),
446585
}
@@ -718,6 +857,21 @@ class AcpClient {
718857
this.handleSessionUpdate(notification)
719858
return
720859
}
860+
if (
861+
event.type === "notification" &&
862+
(event.method === "workspace/changes/updated" ||
863+
event.method === "_devo/workspace/changes/updated") &&
864+
event.params
865+
) {
866+
const payload = this.validateTransportPayload<WorkspaceChangesUpdatedPayload>(
867+
event.method,
868+
"incomingNotification",
869+
event.params,
870+
)
871+
if (!payload) return
872+
this.handleWorkspaceChangesUpdated(payload)
873+
return
874+
}
721875
if (event.type === "request" && event.id !== undefined && event.method) {
722876
const params = this.validateTransportPayload(event.method, "incomingRequest", event.params)
723877
if (!params) return
@@ -973,6 +1127,10 @@ class AcpClient {
9731127
const payload = (original as { RequestUserInput: Record<string, unknown> }).RequestUserInput
9741128
this.handleRequestUserInput(sessionId, directory, payload)
9751129
}
1130+
const workspaceChanges = workspaceChangesUpdatedFromOriginalEvent(original)
1131+
if (workspaceChanges) {
1132+
this.handleWorkspaceChangesUpdated(workspaceChanges, directory)
1133+
}
9761134
if ("ServerRequestResolved" in original) {
9771135
const payload = (original as { ServerRequestResolved: Record<string, unknown> })
9781136
.ServerRequestResolved
@@ -987,6 +1145,20 @@ class AcpClient {
9871145
}
9881146
}
9891147

1148+
private handleWorkspaceChangesUpdated(
1149+
payload: WorkspaceChangesUpdatedPayload,
1150+
directory?: string,
1151+
): void {
1152+
const event = workspaceChangesUpdatedEventProperties(payload)
1153+
if (!event.sessionID) return
1154+
const emitDirectory =
1155+
directory ?? this.sessionDirectories.get(event.sessionID) ?? this.options.directory ?? defaultCwd()
1156+
this.emit(emitDirectory, {
1157+
type: "workspace.changes.updated",
1158+
properties: event,
1159+
})
1160+
}
1161+
9901162
private handleRequestUserInput(
9911163
sessionId: string,
9921164
directory: string,

0 commit comments

Comments
 (0)