viveworker 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Local iPhone companion for Codex Desktop approvals, plan checks, questions, and notifications on your LAN.",
5
5
  "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "companion-app",
19
19
  "vivecoding"
20
20
  ],
21
- "homepage": "https://lp.hazbase.com/",
21
+ "homepage": "https://viveworker.com/",
22
22
  "repository": "viveworker-dev/viveworker",
23
23
  "type": "module",
24
24
  "bin": {
@@ -64,6 +64,7 @@ const runtime = {
64
64
  threadOwnerClientIds: new Map(),
65
65
  nativeApprovalsByToken: new Map(),
66
66
  nativeApprovalsByRequestKey: new Map(),
67
+ fileApprovalDeltasById: new Map(),
67
68
  planRequestsByToken: new Map(),
68
69
  planRequestsByRequestKey: new Map(),
69
70
  planRequestsByTurnKey: new Map(),
@@ -897,6 +898,16 @@ function buildUnifiedDiffFromApplyPatchSection(section) {
897
898
  return normalizeTimelineDiffText(diffLines.join("\n"));
898
899
  }
899
900
 
901
+ if (section.kind === "delete") {
902
+ const removedCount = bodyLines.filter((line) => line.startsWith("-")).length;
903
+ diffLines.push("deleted file mode 100644");
904
+ diffLines.push(`--- ${diffPathForSide(fileRef, "a")}`);
905
+ diffLines.push("+++ /dev/null");
906
+ diffLines.push(`@@ -1,${Math.max(removedCount, 1)} +0,0 @@`);
907
+ diffLines.push(...bodyLines);
908
+ return normalizeTimelineDiffText(diffLines.join("\n"));
909
+ }
910
+
900
911
  return "";
901
912
  }
902
913
 
@@ -1025,6 +1036,82 @@ async function buildFileEventDiff({ fileState, callId, fileRefs, fileEventType,
1025
1036
  };
1026
1037
  }
1027
1038
 
1039
+ function approvalRequestCallId(params) {
1040
+ if (!isPlainObject(params)) {
1041
+ return "";
1042
+ }
1043
+ return cleanText(params.itemId ?? params.item_id ?? params.callId ?? params.call_id ?? "");
1044
+ }
1045
+
1046
+ async function ensureRolloutFileState(runtime, threadId, rolloutFilePath) {
1047
+ const normalizedRolloutFilePath = cleanText(rolloutFilePath || "");
1048
+ if (!normalizedRolloutFilePath) {
1049
+ return null;
1050
+ }
1051
+
1052
+ let fileState = runtime.fileStates.get(normalizedRolloutFilePath);
1053
+ if (fileState) {
1054
+ return fileState;
1055
+ }
1056
+
1057
+ fileState = {
1058
+ offset: 0,
1059
+ remainder: "",
1060
+ threadId: cleanText(threadId || ""),
1061
+ cwd: await findRolloutThreadCwd(runtime, threadId || ""),
1062
+ applyPatchInputsByCallId: new Map(),
1063
+ startupCutoffMs: 0,
1064
+ skipPartialLine: false,
1065
+ };
1066
+ runtime.fileStates.set(normalizedRolloutFilePath, fileState);
1067
+ return fileState;
1068
+ }
1069
+
1070
+ async function buildApprovalPayloadDeltaFromRollout({ runtime, conversationId, params }) {
1071
+ const callId = approvalRequestCallId(params);
1072
+ const threadId = cleanText(conversationId || params?.threadId || params?.thread_id || "");
1073
+ if (!callId || !threadId) {
1074
+ return null;
1075
+ }
1076
+
1077
+ const rolloutFilePath = findRolloutFileForThread(runtime, threadId);
1078
+ if (!rolloutFilePath) {
1079
+ return null;
1080
+ }
1081
+
1082
+ const fileState = await ensureRolloutFileState(runtime, threadId, rolloutFilePath);
1083
+ if (!fileState) {
1084
+ return null;
1085
+ }
1086
+
1087
+ const storedPatch = await findStoredApplyPatchInput({
1088
+ fileState,
1089
+ callId,
1090
+ rolloutFilePath,
1091
+ });
1092
+ if (!storedPatch?.inputText) {
1093
+ return null;
1094
+ }
1095
+
1096
+ const sections = parseApplyPatchSections(storedPatch.inputText);
1097
+ const fileRefs = normalizeTimelineFileRefs(sections.map((section) => section?.fileRef).filter(Boolean));
1098
+ const diffText = normalizeTimelineDiffText(
1099
+ sections
1100
+ .map((section) => buildUnifiedDiffFromApplyPatchSection(section))
1101
+ .filter(Boolean)
1102
+ .join("\n\n")
1103
+ );
1104
+ const counts = diffLineCounts(diffText);
1105
+ return {
1106
+ fileRefs,
1107
+ diffText,
1108
+ diffAvailable: Boolean(diffText),
1109
+ diffSource: diffText ? "apply_patch" : "",
1110
+ diffAddedLines: counts.addedLines,
1111
+ diffRemovedLines: counts.removedLines,
1112
+ };
1113
+ }
1114
+
1028
1115
  function normalizeUnifiedDiffSectionFileRef(value) {
1029
1116
  const normalized = cleanText(value || "");
1030
1117
  if (!normalized || normalized === "/dev/null") {
@@ -1152,6 +1239,11 @@ function normalizeHistoryItem(raw) {
1152
1239
  messageText,
1153
1240
  imagePaths: normalizeTimelineImagePaths(raw.imagePaths ?? raw.localImagePaths ?? []),
1154
1241
  fileRefs: normalizeTimelineFileRefs(raw.fileRefs ?? extractTimelineFileRefs(messageText)),
1242
+ diffText: normalizeTimelineDiffText(raw.diffText ?? ""),
1243
+ diffSource: normalizeTimelineDiffSource(raw.diffSource ?? ""),
1244
+ diffAvailable: raw.diffAvailable === true || Boolean(raw.diffText),
1245
+ diffAddedLines: Math.max(0, Number(raw.diffAddedLines) || 0),
1246
+ diffRemovedLines: Math.max(0, Number(raw.diffRemovedLines) || 0),
1155
1247
  outcome,
1156
1248
  createdAtMs,
1157
1249
  readOnly: raw.readOnly !== false,
@@ -1505,6 +1597,12 @@ function recordActionHistoryItem({
1505
1597
  threadLabel = "",
1506
1598
  messageText,
1507
1599
  summary,
1600
+ fileRefs = [],
1601
+ diffText = "",
1602
+ diffSource = "",
1603
+ diffAvailable = false,
1604
+ diffAddedLines = 0,
1605
+ diffRemovedLines = 0,
1508
1606
  outcome = "",
1509
1607
  }) {
1510
1608
  const item = {
@@ -1516,6 +1614,12 @@ function recordActionHistoryItem({
1516
1614
  threadLabel,
1517
1615
  summary,
1518
1616
  messageText,
1617
+ fileRefs,
1618
+ diffText,
1619
+ diffSource,
1620
+ diffAvailable,
1621
+ diffAddedLines,
1622
+ diffRemovedLines,
1519
1623
  outcome,
1520
1624
  createdAtMs: Date.now(),
1521
1625
  readOnly: true,
@@ -3361,18 +3465,23 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
3361
3465
  for (const [requestKey, request] of nextKeys) {
3362
3466
  const existing = runtime.nativeApprovalsByRequestKey.get(requestKey);
3363
3467
  if (existing) {
3364
- existing.ownerClientId =
3365
- runtime.threadOwnerClientIds.get(conversationId) ??
3366
- existing.ownerClientId ??
3367
- null;
3368
- continue;
3369
- }
3370
-
3371
- if (previousKeys.has(requestKey)) {
3468
+ const changed = await refreshNativeApprovalFromRequest({
3469
+ config,
3470
+ runtime,
3471
+ conversationId,
3472
+ request,
3473
+ approval: existing,
3474
+ });
3475
+ if (changed) {
3476
+ const fileKeys = isPlainObject(existing.rawParams) ? Object.keys(existing.rawParams).join(",") : "";
3477
+ console.log(
3478
+ `[native-approval-refresh] ${requestKey} | files=${normalizeTimelineFileRefs(existing.fileRefs ?? []).length} | diff=${existing.diffAvailable || Boolean(existing.diffText) ? "yes" : "no"} | keys=${fileKeys || "none"}`
3479
+ );
3480
+ }
3372
3481
  continue;
3373
3482
  }
3374
3483
 
3375
- const approval = createNativeApproval({
3484
+ const approval = await createNativeApproval({
3376
3485
  config,
3377
3486
  runtime,
3378
3487
  conversationId,
@@ -3385,6 +3494,14 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
3385
3494
  runtime.nativeApprovalsByRequestKey.set(requestKey, approval);
3386
3495
  runtime.nativeApprovalsByToken.set(approval.token, approval);
3387
3496
 
3497
+ if (previousKeys.has(requestKey)) {
3498
+ const fileKeys = isPlainObject(approval.rawParams) ? Object.keys(approval.rawParams).join(",") : "";
3499
+ console.log(
3500
+ `[native-approval-recovered] ${requestKey} | files=${normalizeTimelineFileRefs(approval.fileRefs ?? []).length} | diff=${approval.diffAvailable || Boolean(approval.diffText) ? "yes" : "no"} | keys=${fileKeys || "none"}`
3501
+ );
3502
+ continue;
3503
+ }
3504
+
3388
3505
  try {
3389
3506
  await publishNtfy(config, {
3390
3507
  kind: "native_approval",
@@ -3395,7 +3512,10 @@ async function syncNativeApprovals({ config, runtime, state, conversationId, pre
3395
3512
  clickUrl: approval.reviewUrl,
3396
3513
  actions: buildNativeApprovalActions(approval.reviewUrl, config.defaultLocale),
3397
3514
  });
3398
- console.log(`[native-approval] ${requestKey} | ${approval.title}`);
3515
+ const fileKeys = isPlainObject(approval.rawParams) ? Object.keys(approval.rawParams).join(",") : "";
3516
+ console.log(
3517
+ `[native-approval] ${requestKey} | ${approval.title} | files=${normalizeTimelineFileRefs(approval.fileRefs ?? []).length} | diff=${approval.diffAvailable || Boolean(approval.diffText) ? "yes" : "no"} | keys=${fileKeys || "none"}`
3518
+ );
3399
3519
  } catch (error) {
3400
3520
  console.error(`[native-approval-error] ${requestKey} | ${error.message}`);
3401
3521
  }
@@ -3773,7 +3893,29 @@ async function syncGenericUserInputRequests({
3773
3893
  }
3774
3894
  }
3775
3895
 
3776
- function createNativeApproval({ config, runtime, conversationId, request, now = Date.now() }) {
3896
+ async function createNativeApproval({ config, runtime, conversationId, request, now = Date.now() }) {
3897
+ const token = crypto.randomBytes(18).toString("hex");
3898
+ const payload = await buildNativeApprovalPayload({
3899
+ config,
3900
+ runtime,
3901
+ conversationId,
3902
+ request,
3903
+ token,
3904
+ });
3905
+ if (!payload) {
3906
+ return null;
3907
+ }
3908
+
3909
+ return {
3910
+ token,
3911
+ ...payload,
3912
+ createdAtMs: now,
3913
+ resolved: false,
3914
+ resolving: false,
3915
+ };
3916
+ }
3917
+
3918
+ async function buildNativeApprovalPayload({ config, runtime, conversationId, request, token }) {
3777
3919
  const kind = nativeApprovalKind(request.method);
3778
3920
  if (!kind) {
3779
3921
  return null;
@@ -3783,23 +3925,40 @@ function createNativeApproval({ config, runtime, conversationId, request, now =
3783
3925
  if (requestId == null) {
3784
3926
  return null;
3785
3927
  }
3928
+ const requestKey = nativeRequestKey(conversationId, requestId);
3786
3929
 
3787
3930
  const threadLabel = getNativeThreadLabel({
3788
3931
  runtime,
3789
3932
  conversationId,
3790
3933
  cwd: request.params?.cwd ?? request.params?.grantRoot ?? "",
3791
3934
  });
3792
- const token = crypto.randomBytes(18).toString("hex");
3793
3935
  const reviewUrl = `${config.nativeApprovalPublicBaseUrl}/native-approvals/${token}`;
3794
3936
  const title = formatTitle(config.approvalTitle, threadLabel);
3795
3937
  const rawParams = isPlainObject(request.params) ? cloneJson(request.params) : {};
3796
- const fileRefs = kind === "file" ? extractApprovalFileRefs(rawParams) : [];
3797
- const diffText = kind === "file" ? extractApprovalDiffText(rawParams, fileRefs) : "";
3798
- const diffCounts = diffLineCounts(diffText);
3938
+ const approvalIds = kind === "file" ? collectFileApprovalCorrelationIds(rawParams, requestId) : [];
3939
+ const requestDelta = kind === "file" ? extractApprovalPayloadDelta(rawParams, "approval_request") : null;
3940
+ const cachedDelta =
3941
+ kind === "file"
3942
+ ? approvalIds.reduce(
3943
+ (merged, approvalId) => mergeApprovalPayloadDelta(merged, runtime.fileApprovalDeltasById.get(approvalId) ?? null),
3944
+ null
3945
+ )
3946
+ : null;
3947
+ const rolloutDelta =
3948
+ kind === "file"
3949
+ ? await buildApprovalPayloadDeltaFromRollout({
3950
+ runtime,
3951
+ conversationId,
3952
+ params: rawParams,
3953
+ })
3954
+ : null;
3955
+ const mergedDelta =
3956
+ kind === "file"
3957
+ ? mergeApprovalPayloadDelta(mergeApprovalPayloadDelta(requestDelta, cachedDelta), rolloutDelta)
3958
+ : null;
3799
3959
  const messageText = formatNativeApprovalMessage(kind, rawParams, config.defaultLocale);
3800
3960
 
3801
3961
  return {
3802
- token,
3803
3962
  kind,
3804
3963
  title,
3805
3964
  threadLabel,
@@ -3807,21 +3966,95 @@ function createNativeApproval({ config, runtime, conversationId, request, now =
3807
3966
  reviewUrl,
3808
3967
  conversationId,
3809
3968
  requestId,
3810
- requestKey: nativeRequestKey(conversationId, requestId),
3969
+ requestKey,
3811
3970
  ownerClientId: runtime.threadOwnerClientIds.get(conversationId) ?? null,
3971
+ approvalIds,
3812
3972
  rawParams,
3813
- fileRefs,
3814
- diffText,
3815
- diffAvailable: Boolean(diffText),
3816
- diffSource: diffText ? "approval_request" : "",
3817
- diffAddedLines: diffCounts.addedLines,
3818
- diffRemovedLines: diffCounts.removedLines,
3819
- createdAtMs: now,
3820
- resolved: false,
3821
- resolving: false,
3973
+ fileRefs: normalizeTimelineFileRefs(mergedDelta?.fileRefs ?? []),
3974
+ diffText: normalizeTimelineDiffText(mergedDelta?.diffText ?? ""),
3975
+ diffAvailable: Boolean(mergedDelta?.diffAvailable),
3976
+ diffSource: normalizeTimelineDiffSource(mergedDelta?.diffSource ?? ""),
3977
+ diffAddedLines: Math.max(0, Number(mergedDelta?.diffAddedLines) || 0),
3978
+ diffRemovedLines: Math.max(0, Number(mergedDelta?.diffRemovedLines) || 0),
3822
3979
  };
3823
3980
  }
3824
3981
 
3982
+ async function refreshNativeApprovalFromRequest({ config, runtime, conversationId, request, approval }) {
3983
+ if (!approval?.token) {
3984
+ return false;
3985
+ }
3986
+ const payload = await buildNativeApprovalPayload({
3987
+ config,
3988
+ runtime,
3989
+ conversationId,
3990
+ request,
3991
+ token: approval.token,
3992
+ });
3993
+ if (!payload) {
3994
+ return false;
3995
+ }
3996
+
3997
+ const before = JSON.stringify({
3998
+ kind: approval.kind,
3999
+ title: approval.title,
4000
+ threadLabel: approval.threadLabel,
4001
+ messageText: approval.messageText,
4002
+ reviewUrl: approval.reviewUrl,
4003
+ conversationId: approval.conversationId,
4004
+ requestId: approval.requestId,
4005
+ requestKey: approval.requestKey,
4006
+ ownerClientId: approval.ownerClientId,
4007
+ approvalIds: approval.approvalIds,
4008
+ rawParams: approval.rawParams,
4009
+ fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
4010
+ diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
4011
+ diffAvailable: Boolean(approval.diffAvailable),
4012
+ diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
4013
+ diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
4014
+ diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
4015
+ });
4016
+
4017
+ approval.kind = payload.kind;
4018
+ approval.title = payload.title;
4019
+ approval.threadLabel = payload.threadLabel;
4020
+ approval.messageText = payload.messageText;
4021
+ approval.reviewUrl = payload.reviewUrl;
4022
+ approval.conversationId = payload.conversationId;
4023
+ approval.requestId = payload.requestId;
4024
+ approval.requestKey = payload.requestKey;
4025
+ approval.ownerClientId = payload.ownerClientId;
4026
+ approval.approvalIds = payload.approvalIds;
4027
+ approval.rawParams = payload.rawParams;
4028
+ approval.fileRefs = payload.fileRefs;
4029
+ approval.diffText = payload.diffText;
4030
+ approval.diffAvailable = payload.diffAvailable;
4031
+ approval.diffSource = payload.diffSource;
4032
+ approval.diffAddedLines = payload.diffAddedLines;
4033
+ approval.diffRemovedLines = payload.diffRemovedLines;
4034
+
4035
+ const after = JSON.stringify({
4036
+ kind: approval.kind,
4037
+ title: approval.title,
4038
+ threadLabel: approval.threadLabel,
4039
+ messageText: approval.messageText,
4040
+ reviewUrl: approval.reviewUrl,
4041
+ conversationId: approval.conversationId,
4042
+ requestId: approval.requestId,
4043
+ requestKey: approval.requestKey,
4044
+ ownerClientId: approval.ownerClientId,
4045
+ approvalIds: approval.approvalIds,
4046
+ rawParams: approval.rawParams,
4047
+ fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
4048
+ diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
4049
+ diffAvailable: Boolean(approval.diffAvailable),
4050
+ diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
4051
+ diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
4052
+ diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
4053
+ });
4054
+
4055
+ return before !== after;
4056
+ }
4057
+
3825
4058
  function createPlanQuestionRequest({ runtime, conversationId, request, sourceClientId, now = Date.now() }) {
3826
4059
  if (!isPlanQuestionRequest(request)) {
3827
4060
  return null;
@@ -5482,6 +5715,11 @@ class NativeIpcClient {
5482
5715
  return;
5483
5716
  }
5484
5717
 
5718
+ if (message.method === "item/fileChange/outputDelta") {
5719
+ this.handleFileChangeOutputDelta(message);
5720
+ return;
5721
+ }
5722
+
5485
5723
  if (isUserInputRequestedBroadcastMethod(message.method)) {
5486
5724
  await this.handleUserInputRequested(message);
5487
5725
  }
@@ -5540,6 +5778,15 @@ class NativeIpcClient {
5540
5778
  await this.onUserInputRequested(normalized);
5541
5779
  }
5542
5780
 
5781
+ handleFileChangeOutputDelta(message) {
5782
+ const params = isPlainObject(message?.params) ? message.params : {};
5783
+ if (!rememberFileApprovalDelta(this.runtime, params)) {
5784
+ return;
5785
+ }
5786
+ const approvalIds = collectFileApprovalCorrelationIds(params);
5787
+ console.log(`[ipc-file-change-delta] approvalIds=${approvalIds.join(",") || "unknown"}`);
5788
+ }
5789
+
5543
5790
  write(message) {
5544
5791
  if (!this.socket) {
5545
5792
  return;
@@ -5948,10 +6195,20 @@ function extractApprovalFileRefs(params) {
5948
6195
  const candidateKeys = new Set([
5949
6196
  "file",
5950
6197
  "files",
6198
+ "fileChange",
6199
+ "fileChanges",
6200
+ "change",
6201
+ "changes",
5951
6202
  "path",
5952
6203
  "paths",
5953
6204
  "filePath",
5954
6205
  "filePaths",
6206
+ "filename",
6207
+ "filenames",
6208
+ "fileName",
6209
+ "fileNames",
6210
+ "file_name",
6211
+ "file_names",
5955
6212
  "fileRef",
5956
6213
  "fileRefs",
5957
6214
  "updatedFile",
@@ -5962,6 +6219,16 @@ function extractApprovalFileRefs(params) {
5962
6219
  "targetFiles",
5963
6220
  "touchedFile",
5964
6221
  "touchedFiles",
6222
+ "relativePath",
6223
+ "relativePaths",
6224
+ "oldPath",
6225
+ "newPath",
6226
+ "old_path",
6227
+ "new_path",
6228
+ "sourcePath",
6229
+ "sourcePaths",
6230
+ "destinationPath",
6231
+ "destinationPaths",
5965
6232
  ]);
5966
6233
 
5967
6234
  function visit(value, parentKey = "", depth = 0) {
@@ -5989,7 +6256,23 @@ function extractApprovalFileRefs(params) {
5989
6256
  }
5990
6257
 
5991
6258
  if (candidateKeys.has(normalizedParentKey)) {
5992
- const directRef = cleanText(value.fileRef ?? value.filePath ?? value.path ?? value.filename ?? value.name ?? "");
6259
+ const directRef = cleanText(
6260
+ value.fileRef ??
6261
+ value.filePath ??
6262
+ value.path ??
6263
+ value.filename ??
6264
+ value.fileName ??
6265
+ value.file_name ??
6266
+ value.relativePath ??
6267
+ value.oldPath ??
6268
+ value.newPath ??
6269
+ value.old_path ??
6270
+ value.new_path ??
6271
+ value.sourcePath ??
6272
+ value.destinationPath ??
6273
+ value.name ??
6274
+ ""
6275
+ );
5993
6276
  if (directRef) {
5994
6277
  refs.push(directRef);
5995
6278
  }
@@ -6007,6 +6290,144 @@ function extractApprovalFileRefs(params) {
6007
6290
  return normalizeTimelineFileRefs(refs);
6008
6291
  }
6009
6292
 
6293
+ function buildUnifiedDiffFromBeforeAfter({ fileRef = "", beforeText = "", afterText = "" }) {
6294
+ const normalizedFileRef = cleanTimelineFileRef(fileRef);
6295
+ if (!normalizedFileRef) {
6296
+ return "";
6297
+ }
6298
+ const before = String(beforeText ?? "");
6299
+ const after = String(afterText ?? "");
6300
+ if (!before && !after) {
6301
+ return "";
6302
+ }
6303
+ const beforeLines = before.replace(/\r\n/gu, "\n").split("\n");
6304
+ const afterLines = after.replace(/\r\n/gu, "\n").split("\n");
6305
+ const diffLines = [`diff --git ${diffPathForSide(normalizedFileRef, "a")} ${diffPathForSide(normalizedFileRef, "b")}`];
6306
+
6307
+ if (!before && after) {
6308
+ diffLines.push("new file mode 100644");
6309
+ diffLines.push("--- /dev/null");
6310
+ diffLines.push(`+++ ${diffPathForSide(normalizedFileRef, "b")}`);
6311
+ diffLines.push(`@@ -0,0 +1,${Math.max(afterLines.length, 1)} @@`);
6312
+ diffLines.push(...afterLines.map((line) => `+${line}`));
6313
+ return normalizeTimelineDiffText(diffLines.join("\n"));
6314
+ }
6315
+
6316
+ diffLines.push(`--- ${diffPathForSide(normalizedFileRef, "a")}`);
6317
+ diffLines.push(`+++ ${diffPathForSide(normalizedFileRef, "b")}`);
6318
+ diffLines.push(`@@ -1,${Math.max(beforeLines.length, 1)} +1,${Math.max(afterLines.length, 1)} @@`);
6319
+ diffLines.push(...beforeLines.map((line) => `-${line}`));
6320
+ diffLines.push(...afterLines.map((line) => `+${line}`));
6321
+ return normalizeTimelineDiffText(diffLines.join("\n"));
6322
+ }
6323
+
6324
+ function extractStructuredApprovalDiffText(value, fallbackFileRefs = []) {
6325
+ const normalizedFallbackRefs = normalizeTimelineFileRefs(fallbackFileRefs);
6326
+ if (typeof value === "string") {
6327
+ return normalizeTimelineDiffText(value);
6328
+ }
6329
+
6330
+ if (Array.isArray(value)) {
6331
+ const sections = value
6332
+ .map((item) => extractStructuredApprovalDiffText(item, normalizedFallbackRefs))
6333
+ .filter(Boolean);
6334
+ return normalizeTimelineDiffText(sections.join("\n\n"));
6335
+ }
6336
+
6337
+ if (!isPlainObject(value)) {
6338
+ return "";
6339
+ }
6340
+
6341
+ const explicitDiff =
6342
+ extractStructuredApprovalDiffText(
6343
+ value.diff ??
6344
+ value.patch ??
6345
+ value.patchText ??
6346
+ value.diffText ??
6347
+ value.unifiedDiff ??
6348
+ value.unified_diff ??
6349
+ value.unifiedPatch ??
6350
+ value.unified_patch ??
6351
+ value.unifiedPatchText ??
6352
+ value.unified_patch_text ??
6353
+ value.text ??
6354
+ value.value ??
6355
+ value.content ??
6356
+ value.delta ??
6357
+ value.output ??
6358
+ value.fileChanges ??
6359
+ value.fileChange ??
6360
+ value.changes ??
6361
+ value.change ??
6362
+ null,
6363
+ normalizedFallbackRefs
6364
+ ) || "";
6365
+ if (explicitDiff) {
6366
+ return explicitDiff;
6367
+ }
6368
+
6369
+ const fileRef =
6370
+ cleanTimelineFileRef(
6371
+ value.fileRef ??
6372
+ value.filePath ??
6373
+ value.path ??
6374
+ value.filename ??
6375
+ value.fileName ??
6376
+ value.file_name ??
6377
+ value.relativePath ??
6378
+ value.name ??
6379
+ value.targetFile ??
6380
+ value.newPath ??
6381
+ value.oldPath ??
6382
+ value.new_path ??
6383
+ value.old_path ??
6384
+ value.sourcePath ??
6385
+ value.destinationPath ??
6386
+ normalizedFallbackRefs[0] ??
6387
+ ""
6388
+ ) || normalizedFallbackRefs[0] || "";
6389
+ const beforeText =
6390
+ value.before ??
6391
+ value.beforeText ??
6392
+ value.oldText ??
6393
+ value.old_text ??
6394
+ value.originalText ??
6395
+ value.original_text ??
6396
+ value.previousText ??
6397
+ value.previous_text ??
6398
+ value.contentBefore ??
6399
+ value.beforeContent ??
6400
+ value.oldContent ??
6401
+ value.old_content ??
6402
+ "";
6403
+ const afterText =
6404
+ value.after ??
6405
+ value.afterText ??
6406
+ value.newText ??
6407
+ value.new_text ??
6408
+ value.updatedText ??
6409
+ value.updated_text ??
6410
+ value.currentText ??
6411
+ value.current_text ??
6412
+ value.contentAfter ??
6413
+ value.afterContent ??
6414
+ value.newContent ??
6415
+ value.new_content ??
6416
+ "";
6417
+ const beforeAfterDiff = buildUnifiedDiffFromBeforeAfter({ fileRef, beforeText, afterText });
6418
+ if (beforeAfterDiff) {
6419
+ return beforeAfterDiff;
6420
+ }
6421
+
6422
+ for (const child of Object.values(value)) {
6423
+ const nested = extractStructuredApprovalDiffText(child, fileRef ? [fileRef] : normalizedFallbackRefs);
6424
+ if (nested) {
6425
+ return nested;
6426
+ }
6427
+ }
6428
+ return "";
6429
+ }
6430
+
6010
6431
  function extractApprovalDiffText(params, fileRefs = []) {
6011
6432
  if (!isPlainObject(params)) {
6012
6433
  return "";
@@ -6018,8 +6439,13 @@ function extractApprovalDiffText(params, fileRefs = []) {
6018
6439
  "patch",
6019
6440
  "patchText",
6020
6441
  "unifiedDiff",
6442
+ "unified_diff",
6021
6443
  "diffPreview",
6022
6444
  "diffString",
6445
+ "fileChange",
6446
+ "fileChanges",
6447
+ "change",
6448
+ "changes",
6023
6449
  ]);
6024
6450
 
6025
6451
  let best = "";
@@ -6067,6 +6493,14 @@ function extractApprovalDiffText(params, fileRefs = []) {
6067
6493
  return;
6068
6494
  }
6069
6495
 
6496
+ const normalizedParentKey = cleanText(parentKey);
6497
+ if (candidateKeys.has(normalizedParentKey)) {
6498
+ considerText(extractStructuredApprovalDiffText(value, fileRefs));
6499
+ if (best) {
6500
+ return;
6501
+ }
6502
+ }
6503
+
6070
6504
  for (const [key, child] of Object.entries(value)) {
6071
6505
  visit(child, key, depth + 1);
6072
6506
  if (best) {
@@ -6094,6 +6528,214 @@ function extractApprovalDiffText(params, fileRefs = []) {
6094
6528
  return filtered ? normalizeTimelineDiffText(filtered) : best;
6095
6529
  }
6096
6530
 
6531
+ function extractApprovalPayloadDelta(params, diffSource = "approval_request") {
6532
+ const fileRefs = extractApprovalFileRefs(params);
6533
+ const diffText = extractApprovalDiffText(params, fileRefs);
6534
+ const diffCounts = diffLineCounts(diffText);
6535
+ return {
6536
+ fileRefs,
6537
+ diffText,
6538
+ diffAvailable: Boolean(diffText),
6539
+ diffSource: diffText ? diffSource : "",
6540
+ diffAddedLines: diffCounts.addedLines,
6541
+ diffRemovedLines: diffCounts.removedLines,
6542
+ };
6543
+ }
6544
+
6545
+ function mergeApprovalDiffTexts(existingText = "", nextText = "", fileRefs = []) {
6546
+ const existing = normalizeTimelineDiffText(existingText);
6547
+ const next = normalizeTimelineDiffText(nextText);
6548
+ if (!existing) {
6549
+ return next;
6550
+ }
6551
+ if (!next || next === existing) {
6552
+ return existing;
6553
+ }
6554
+ if (next.includes(existing)) {
6555
+ return next;
6556
+ }
6557
+ if (existing.includes(next)) {
6558
+ return existing;
6559
+ }
6560
+
6561
+ const relevantRefs = normalizeTimelineFileRefs(fileRefs);
6562
+ const existingSections = splitUnifiedDiffTextByFile(existing);
6563
+ const nextSections = splitUnifiedDiffTextByFile(next);
6564
+ if (existingSections.length === 0 || nextSections.length === 0) {
6565
+ return normalizeTimelineDiffText([existing, next].join("\n\n"));
6566
+ }
6567
+
6568
+ const mergedSections = [...existingSections];
6569
+ for (const section of nextSections) {
6570
+ const matchIndex = mergedSections.findIndex((candidate) => timelineFileRefsMatch(candidate.fileRef, section.fileRef));
6571
+ if (matchIndex === -1) {
6572
+ mergedSections.push(section);
6573
+ continue;
6574
+ }
6575
+ if ((section.diffText || "").length > (mergedSections[matchIndex].diffText || "").length) {
6576
+ mergedSections[matchIndex] = section;
6577
+ }
6578
+ }
6579
+
6580
+ const filteredSections =
6581
+ relevantRefs.length > 0
6582
+ ? mergedSections.filter((section) => relevantRefs.some((fileRef) => timelineFileRefsMatch(section.fileRef, fileRef)))
6583
+ : mergedSections;
6584
+ return normalizeTimelineDiffText(filteredSections.map((section) => section.diffText).filter(Boolean).join("\n\n"));
6585
+ }
6586
+
6587
+ function mergeApprovalPayloadDelta(base, next) {
6588
+ if (!base && !next) {
6589
+ return null;
6590
+ }
6591
+ if (!base) {
6592
+ return next ? { ...next, fileRefs: normalizeTimelineFileRefs(next.fileRefs ?? []) } : null;
6593
+ }
6594
+ if (!next) {
6595
+ return base ? { ...base, fileRefs: normalizeTimelineFileRefs(base.fileRefs ?? []) } : null;
6596
+ }
6597
+
6598
+ const fileRefs = normalizeTimelineFileRefs([...(base.fileRefs ?? []), ...(next.fileRefs ?? [])]);
6599
+ const diffText = mergeApprovalDiffTexts(base.diffText ?? "", next.diffText ?? "", fileRefs);
6600
+ const diffCounts = diffLineCounts(diffText);
6601
+ return {
6602
+ fileRefs,
6603
+ diffText,
6604
+ diffAvailable: Boolean(diffText) || base.diffAvailable === true || next.diffAvailable === true,
6605
+ diffSource: normalizeTimelineDiffSource(next.diffSource || base.diffSource || ""),
6606
+ diffAddedLines: diffCounts.addedLines,
6607
+ diffRemovedLines: diffCounts.removedLines,
6608
+ };
6609
+ }
6610
+
6611
+ function collectFileApprovalCorrelationIds(params, fallbackRequestId = "") {
6612
+ const ids = new Set();
6613
+ const pushValue = (value) => {
6614
+ const normalized = cleanText(value ?? "");
6615
+ if (normalized) {
6616
+ ids.add(normalized);
6617
+ }
6618
+ };
6619
+
6620
+ pushValue(fallbackRequestId);
6621
+ if (isPlainObject(params)) {
6622
+ pushValue(params.approvalId);
6623
+ pushValue(params.requestId);
6624
+ pushValue(params.id);
6625
+ }
6626
+ return [...ids.values()];
6627
+ }
6628
+
6629
+ function approvalCorrelationIds(approval) {
6630
+ const ids = new Set();
6631
+ if (approval?.requestId != null) {
6632
+ ids.add(cleanText(approval.requestId));
6633
+ }
6634
+ if (Array.isArray(approval?.approvalIds)) {
6635
+ for (const approvalId of approval.approvalIds) {
6636
+ const normalized = cleanText(approvalId);
6637
+ if (normalized) {
6638
+ ids.add(normalized);
6639
+ }
6640
+ }
6641
+ }
6642
+ const rawParams = isPlainObject(approval?.rawParams) ? approval.rawParams : {};
6643
+ for (const candidate of [rawParams.approvalId, rawParams.requestId, rawParams.id]) {
6644
+ const normalized = cleanText(candidate ?? "");
6645
+ if (normalized) {
6646
+ ids.add(normalized);
6647
+ }
6648
+ }
6649
+ return [...ids.values()];
6650
+ }
6651
+
6652
+ function applyApprovalPayloadDeltaToApproval(approval, delta) {
6653
+ if (!approval || !delta) {
6654
+ return false;
6655
+ }
6656
+ const merged = mergeApprovalPayloadDelta(
6657
+ {
6658
+ fileRefs: approval.fileRefs ?? [],
6659
+ diffText: approval.diffText ?? "",
6660
+ diffAvailable: approval.diffAvailable === true,
6661
+ diffSource: approval.diffSource ?? "",
6662
+ diffAddedLines: approval.diffAddedLines ?? 0,
6663
+ diffRemovedLines: approval.diffRemovedLines ?? 0,
6664
+ },
6665
+ delta
6666
+ );
6667
+ if (!merged) {
6668
+ return false;
6669
+ }
6670
+ const changed =
6671
+ JSON.stringify([
6672
+ normalizeTimelineFileRefs(approval.fileRefs ?? []),
6673
+ normalizeTimelineDiffText(approval.diffText ?? ""),
6674
+ Boolean(approval.diffAvailable),
6675
+ normalizeTimelineDiffSource(approval.diffSource ?? ""),
6676
+ Math.max(0, Number(approval.diffAddedLines) || 0),
6677
+ Math.max(0, Number(approval.diffRemovedLines) || 0),
6678
+ ]) !==
6679
+ JSON.stringify([
6680
+ normalizeTimelineFileRefs(merged.fileRefs ?? []),
6681
+ normalizeTimelineDiffText(merged.diffText ?? ""),
6682
+ Boolean(merged.diffAvailable),
6683
+ normalizeTimelineDiffSource(merged.diffSource ?? ""),
6684
+ Math.max(0, Number(merged.diffAddedLines) || 0),
6685
+ Math.max(0, Number(merged.diffRemovedLines) || 0),
6686
+ ]);
6687
+ approval.fileRefs = normalizeTimelineFileRefs(merged.fileRefs ?? []);
6688
+ approval.diffText = normalizeTimelineDiffText(merged.diffText ?? "");
6689
+ approval.diffAvailable = Boolean(merged.diffAvailable);
6690
+ approval.diffSource = normalizeTimelineDiffSource(merged.diffSource ?? "");
6691
+ approval.diffAddedLines = Math.max(0, Number(merged.diffAddedLines) || 0);
6692
+ approval.diffRemovedLines = Math.max(0, Number(merged.diffRemovedLines) || 0);
6693
+ return changed;
6694
+ }
6695
+
6696
+ function rememberFileApprovalDelta(runtime, params) {
6697
+ const approvalIds = collectFileApprovalCorrelationIds(params);
6698
+ if (approvalIds.length === 0) {
6699
+ return false;
6700
+ }
6701
+
6702
+ const delta = extractApprovalPayloadDelta(params, "approval_request");
6703
+ if (!delta.diffAvailable && normalizeTimelineFileRefs(delta.fileRefs ?? []).length === 0) {
6704
+ return false;
6705
+ }
6706
+
6707
+ let changed = false;
6708
+ for (const approvalId of approvalIds) {
6709
+ const previous = runtime.fileApprovalDeltasById.get(approvalId) ?? null;
6710
+ const next = mergeApprovalPayloadDelta(previous, delta);
6711
+ if (!next) {
6712
+ continue;
6713
+ }
6714
+ runtime.fileApprovalDeltasById.set(approvalId, next);
6715
+ changed = true;
6716
+ }
6717
+
6718
+ if (runtime.fileApprovalDeltasById.size > 256) {
6719
+ const oldestKey = runtime.fileApprovalDeltasById.keys().next().value;
6720
+ if (oldestKey) {
6721
+ runtime.fileApprovalDeltasById.delete(oldestKey);
6722
+ }
6723
+ }
6724
+
6725
+ if (!changed) {
6726
+ return false;
6727
+ }
6728
+
6729
+ for (const approval of runtime.nativeApprovalsByToken.values()) {
6730
+ const matches = approvalCorrelationIds(approval).some((approvalId) => approvalIds.includes(approvalId));
6731
+ if (!matches) {
6732
+ continue;
6733
+ }
6734
+ applyApprovalPayloadDeltaToApproval(approval, delta);
6735
+ }
6736
+ return true;
6737
+ }
6738
+
6097
6739
  function buildNativeApprovalActions(reviewUrl, locale = config?.defaultLocale || DEFAULT_LOCALE) {
6098
6740
  return [{ action: "view", label: t(locale, "server.action.review"), url: reviewUrl, clear: true }];
6099
6741
  }
@@ -7729,6 +8371,11 @@ function buildHistoryDetail(item, locale, runtime = null) {
7729
8371
  createdAtMs: Number(item.createdAtMs) || 0,
7730
8372
  messageHtml: renderMessageHtml(item.messageText, `<p>${escapeHtml(t(locale, "detail.detailUnavailable"))}</p>`),
7731
8373
  fileRefs: normalizeTimelineFileRefs(item.fileRefs ?? []),
8374
+ diffText: normalizeTimelineDiffText(item.diffText ?? ""),
8375
+ diffAvailable: item.diffAvailable === true || Boolean(item.diffText),
8376
+ diffSource: normalizeTimelineDiffSource(item.diffSource ?? ""),
8377
+ diffAddedLines: Math.max(0, Number(item.diffAddedLines) || 0),
8378
+ diffRemovedLines: Math.max(0, Number(item.diffRemovedLines) || 0),
7732
8379
  interruptNotice: interruptedDetailNotice(item.messageText, locale),
7733
8380
  readOnly: true,
7734
8381
  reply: replyEnabled
@@ -8357,8 +9004,15 @@ async function handleNativeApprovalDecision({ config, runtime, state, approval,
8357
9004
  stableId: `approval:${approval.requestKey}:${Date.now()}`,
8358
9005
  token: approval.token,
8359
9006
  title: approval.title,
9007
+ threadLabel: approval.threadLabel || "",
8360
9008
  messageText: `${approvalDecisionMessage(decision, config.defaultLocale)}\n\n${approval.messageText}`,
8361
9009
  summary: approvalDecisionMessage(decision, config.defaultLocale),
9010
+ fileRefs: normalizeTimelineFileRefs(approval.fileRefs ?? []),
9011
+ diffText: normalizeTimelineDiffText(approval.diffText ?? ""),
9012
+ diffSource: normalizeTimelineDiffSource(approval.diffSource ?? ""),
9013
+ diffAvailable: approval.diffAvailable === true || Boolean(approval.diffText),
9014
+ diffAddedLines: Math.max(0, Number(approval.diffAddedLines) || 0),
9015
+ diffRemovedLines: Math.max(0, Number(approval.diffRemovedLines) || 0),
8362
9016
  outcome: decision === "accept" ? "approved" : "rejected",
8363
9017
  });
8364
9018
  if (stateChanged) {
package/web/app.css CHANGED
@@ -1867,6 +1867,16 @@ code {
1867
1867
  align-items: center;
1868
1868
  }
1869
1869
 
1870
+ .detail-diff-card__toggle {
1871
+ width: 100%;
1872
+ padding: 0;
1873
+ border: 0;
1874
+ background: transparent;
1875
+ color: inherit;
1876
+ text-align: left;
1877
+ cursor: pointer;
1878
+ }
1879
+
1870
1880
  .detail-diff-card__title-wrap {
1871
1881
  display: inline-flex;
1872
1882
  align-items: center;
@@ -1888,6 +1898,23 @@ code {
1888
1898
  line-height: 1.3;
1889
1899
  }
1890
1900
 
1901
+ .detail-diff-card__header-right {
1902
+ display: inline-flex;
1903
+ align-items: center;
1904
+ gap: 0.52rem;
1905
+ }
1906
+
1907
+ .detail-diff-card__chevron {
1908
+ width: 0.92rem;
1909
+ height: 0.92rem;
1910
+ color: rgba(202, 220, 233, 0.72);
1911
+ transition: transform 160ms ease;
1912
+ }
1913
+
1914
+ .detail-diff-card__toggle.is-open .detail-diff-card__chevron {
1915
+ transform: rotate(90deg);
1916
+ }
1917
+
1891
1918
  .detail-diff-card__notice {
1892
1919
  margin: 0;
1893
1920
  color: var(--muted);
@@ -1902,6 +1929,27 @@ code {
1902
1929
  line-height: 1.55;
1903
1930
  }
1904
1931
 
1932
+ .detail-page-copy--approval {
1933
+ margin: 0.78rem 0 0.48rem;
1934
+ padding: 0.06rem 0;
1935
+ display: grid;
1936
+ gap: 0;
1937
+ line-height: 1.24;
1938
+ }
1939
+
1940
+ .detail-page-copy--approval > * {
1941
+ margin-top: 0;
1942
+ margin-bottom: 0;
1943
+ }
1944
+
1945
+ .detail-page-copy--approval p {
1946
+ line-height: 1.24;
1947
+ }
1948
+
1949
+ .detail-page-copy--approval > :last-child {
1950
+ margin-bottom: 0;
1951
+ }
1952
+
1905
1953
  .detail-page-copy--mobile {
1906
1954
  padding-inline: 0.1rem;
1907
1955
  }
@@ -2850,7 +2898,7 @@ button:disabled {
2850
2898
  gap: 0.2rem;
2851
2899
  }
2852
2900
 
2853
- .bottom-nav__button span,
2901
+ .bottom-nav__button .tab-label,
2854
2902
  .segmented-nav__button span {
2855
2903
  display: block;
2856
2904
  max-width: 100%;
@@ -2875,12 +2923,19 @@ button:disabled {
2875
2923
  display: inline-flex;
2876
2924
  align-items: center;
2877
2925
  justify-content: center;
2926
+ overflow: visible;
2878
2927
  }
2879
2928
 
2880
2929
  .bottom-nav__attention-dot {
2881
2930
  position: absolute;
2882
- top: -0.12rem;
2883
- right: -0.18rem;
2931
+ top: -0.08rem;
2932
+ right: -0.12rem;
2933
+ display: block;
2934
+ max-width: none;
2935
+ overflow: visible;
2936
+ white-space: normal;
2937
+ line-height: 0;
2938
+ z-index: 1;
2884
2939
  width: 0.52rem;
2885
2940
  height: 0.52rem;
2886
2941
  border-radius: 999px;
@@ -3218,7 +3273,7 @@ button:disabled {
3218
3273
  padding: 0.28rem 0.15rem;
3219
3274
  }
3220
3275
 
3221
- .bottom-nav__button span {
3276
+ .bottom-nav__button .tab-label {
3222
3277
  font-size: 0.72rem;
3223
3278
  }
3224
3279
 
package/web/app.js CHANGED
@@ -39,6 +39,7 @@ const state = {
39
39
  pendingListScrollRestore: false,
40
40
  threadFilterInteractionUntilMs: 0,
41
41
  diffThreadExpandedFiles: {},
42
+ detailDiffExpanded: {},
42
43
  choiceLocalDrafts: {},
43
44
  completionReplyDrafts: {},
44
45
  pairError: "",
@@ -2253,6 +2254,26 @@ function toggleDiffThreadFileExpanded(token, fileRef) {
2253
2254
  };
2254
2255
  }
2255
2256
 
2257
+ function detailDiffExpansionKey(detail) {
2258
+ return `${normalizeClientText(detail?.kind || "")}:${normalizeClientText(detail?.token || "")}`;
2259
+ }
2260
+
2261
+ function isDetailDiffExpanded(detail) {
2262
+ const key = detailDiffExpansionKey(detail);
2263
+ return Boolean(key && state.detailDiffExpanded?.[key] === true);
2264
+ }
2265
+
2266
+ function toggleDetailDiffExpanded(detail) {
2267
+ const key = detailDiffExpansionKey(detail);
2268
+ if (!key || key === ":") {
2269
+ return;
2270
+ }
2271
+ state.detailDiffExpanded = {
2272
+ ...(state.detailDiffExpanded || {}),
2273
+ [key]: !isDetailDiffExpanded(detail),
2274
+ };
2275
+ }
2276
+
2256
2277
  function timelineFileEventFileSummary(item) {
2257
2278
  const labels = normalizeClientFileRefs(item?.fileRefs)
2258
2279
  .map((fileRef) => fileRefLabel(fileRef))
@@ -2989,7 +3010,7 @@ function renderStandardDetailDesktop(detail) {
2989
3010
  <div class="detail-shell">
2990
3011
  ${renderDetailMetaRow(detail, kindInfo)}
2991
3012
  <h2 class="detail-title detail-title--desktop">${escapeHtml(detailDisplayTitle(detail))}</h2>
2992
- ${detail.readOnly ? "" : renderDetailLead(detail, kindInfo)}
3013
+ ${detail.readOnly || detail.kind === "approval" ? "" : renderDetailLead(detail, kindInfo)}
2993
3014
  ${renderPreviousContextCard(detail)}
2994
3015
  ${renderInterruptedDetailNotice(detail)}
2995
3016
  ${
@@ -3027,7 +3048,7 @@ function renderStandardDetailMobile(detail) {
3027
3048
  ? plainIntro
3028
3049
  : `
3029
3050
  <section class="detail-card detail-card--body detail-card--mobile ${spaciousBodyDetail ? "detail-card--message-body" : ""}">
3030
- ${detail.readOnly ? "" : renderDetailLead(detail, kindInfo, { mobile: true })}
3051
+ ${detail.readOnly || detail.kind === "approval" ? "" : renderDetailLead(detail, kindInfo, { mobile: true })}
3031
3052
  <div class="detail-body ${spaciousBodyDetail ? "detail-body--message " : ""}markdown">${detail.messageHtml || ""}</div>
3032
3053
  </section>
3033
3054
  `
@@ -3045,14 +3066,20 @@ function renderStandardDetailMobile(detail) {
3045
3066
  }
3046
3067
 
3047
3068
  function renderDetailPlainIntro(detail, options = {}) {
3048
- if (!["diff_thread", "file_event"].includes(detail?.kind || "")) {
3069
+ if (!["approval", "diff_thread", "file_event"].includes(detail?.kind || "")) {
3049
3070
  return "";
3050
3071
  }
3051
3072
  if (!detail?.messageHtml) {
3052
3073
  return "";
3053
3074
  }
3075
+ const approvalClass = detail?.kind === "approval" ? " detail-page-copy--approval" : "";
3076
+ const approvalLead =
3077
+ detail?.kind === "approval"
3078
+ ? `<p>${escapeHtml(detailIntentText(detail))}</p>`
3079
+ : "";
3054
3080
  return `
3055
- <div class="detail-page-copy ${options.mobile ? "detail-page-copy--mobile" : ""} markdown">
3081
+ <div class="detail-page-copy${approvalClass} ${options.mobile ? "detail-page-copy--mobile" : ""} markdown">
3082
+ ${approvalLead}
3056
3083
  ${detail.messageHtml}
3057
3084
  </div>
3058
3085
  `;
@@ -3206,21 +3233,31 @@ function renderDetailDiffPanel(detail, options = {}) {
3206
3233
 
3207
3234
  const diffText = String(detail?.diffText || "").replace(/\r\n/g, "\n").trim();
3208
3235
  const statsHtml = renderDiffEntryStatsHtml(detail);
3236
+ const expanded = isDetailDiffExpanded(detail);
3209
3237
 
3210
3238
  return `
3211
3239
  <section class="detail-card detail-card--diff ${options.mobile ? "detail-card--mobile" : ""}">
3212
- <div class="detail-diff-card__header">
3213
- <div class="detail-diff-card__title-wrap">
3214
- <span class="detail-diff-card__icon" aria-hidden="true">${renderIcon("diff")}</span>
3215
- <span>${escapeHtml(L("detail.diffTitle"))}</span>
3240
+ <button
3241
+ type="button"
3242
+ class="detail-diff-card__toggle ${expanded ? "is-open" : ""}"
3243
+ data-detail-diff-toggle
3244
+ >
3245
+ <div class="detail-diff-card__header">
3246
+ <div class="detail-diff-card__title-wrap">
3247
+ <span class="detail-diff-card__icon" aria-hidden="true">${renderIcon("diff")}</span>
3248
+ <span>${escapeHtml(L("detail.diffTitle"))}</span>
3249
+ </div>
3250
+ <div class="detail-diff-card__header-right">
3251
+ ${statsHtml ? `<span class="detail-diff-card__stats diff-entry__stats">${statsHtml}</span>` : ""}
3252
+ <span class="detail-diff-card__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
3253
+ </div>
3216
3254
  </div>
3217
- ${statsHtml ? `<span class="detail-diff-card__stats diff-entry__stats">${statsHtml}</span>` : ""}
3218
- </div>
3219
- ${
3220
- diffText
3255
+ </button>
3256
+ ${expanded
3257
+ ? diffText
3221
3258
  ? `<div class="detail-diff-viewer">${renderDiffLines(diffText)}</div>`
3222
3259
  : `<p class="detail-diff-card__notice">${escapeHtml(L("detail.diffUnavailable"))}</p>`
3223
- }
3260
+ : ""}
3224
3261
  </section>
3225
3262
  `;
3226
3263
  }
@@ -4059,6 +4096,18 @@ function bindShellInteractions() {
4059
4096
  });
4060
4097
  }
4061
4098
 
4099
+ for (const button of document.querySelectorAll("[data-detail-diff-toggle]")) {
4100
+ button.addEventListener("click", async (event) => {
4101
+ event.preventDefault();
4102
+ event.stopPropagation();
4103
+ if (!state.currentDetail) {
4104
+ return;
4105
+ }
4106
+ toggleDetailDiffExpanded(state.currentDetail);
4107
+ await renderShell();
4108
+ });
4109
+ }
4110
+
4062
4111
  for (const button of document.querySelectorAll("[data-back-to-list]")) {
4063
4112
  button.addEventListener("click", async () => {
4064
4113
  clearChoiceLocalDraftForItem(state.currentItem);