dsh-review-graph 1.0.1 → 1.0.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ ## 1.0.2
4
+
5
+ Fixes reported from real sessions.
6
+
7
+ * **Generate no longer crashes** when no model is known yet. It failed with
8
+ `Cannot read properties of null (reading 'provider')` in a session whose selection had never been
9
+ announced and whose plan had not loaded; the choice is a pure function now and the request says
10
+ what is missing instead.
11
+ * **"Out of date" means the code moved.** The conversation digest used to count as well, and it moves
12
+ whenever a session grows — switching pages, loading more history, or a generation recording itself
13
+ into the conversation — so answers were labelled expired although no code had changed. A moved
14
+ digest is a note now.
15
+ * **The review pane scrolls to the line it jumped to**, and to the nearest change block when that
16
+ line survived unchanged and sits outside every hunk.
17
+ * **A step prefers the code that implements it.** A flow diagram used to send the reader to a
18
+ documentation file whenever the model anchored a behaviour step there.
19
+ * **The model is never printed as `undefined`.** An absent model was dropped by `JSON.stringify` and
20
+ the card interpolated it anyway; absent values are normalised now, and the label falls back to the
21
+ model that would answer.
22
+
23
+ ## 1.0.1
24
+
25
+ Documentation and CI only. Published to validate the package's trusted publishing configuration,
26
+ which npm expires if it is never used.
27
+
28
+ ## 1.0.0
29
+
30
+ First release: the relationship graph, the review pane, and the optional AI flow diagram.
package/client.js CHANGED
@@ -689,6 +689,7 @@ window.__ModuleLoader__.load({
689
689
  aiNoTalk: '(这段会话还没读到可用于推理的消息)',
690
690
  aiModel: (name) => `模型:${name}`,
691
691
  aiModelUnknown: '模型:未从会话读到,将使用默认 provider',
692
+ aiNoModel: '还没有确定用哪个模型:请确认对话区已选择模型,或稍后重试(计划加载完成后即可)。',
692
693
  aiGenerate: '生成',
693
694
  aiGenerating: '正在推理…(会消耗 token)',
694
695
  aiRebuild: '重新生成',
@@ -712,6 +713,7 @@ window.__ModuleLoader__.load({
712
713
  : `上次失败:${code}`,
713
714
  aiCacheMemory: '本次页面已生成,切页签不丢(刷新后需重新加载)',
714
715
  aiStale: '已过期',
716
+ aiContextMoved: '对话摘要已更新——这份结果是之前的上下文生成的,重新生成会带上新内容。',
715
717
  aiStaleHead: 'HEAD 已经变了(分支上有新提交),这是上一次的结果',
716
718
  aiStaleContext: '对话摘要变了,这是上一次的结果',
717
719
  aiStaleBoth: 'HEAD 与对话摘要都变了,这是上一次的结果',
@@ -824,6 +826,7 @@ window.__ModuleLoader__.load({
824
826
  aiNoTalk: '(this session holds no messages to reason from yet)',
825
827
  aiModel: (name) => `Model: ${name}`,
826
828
  aiModelUnknown: 'Model: not read from this session; the default provider will be used',
829
+ aiNoModel: 'No model is known yet. Check that the conversation has one selected, or try again in a moment.',
827
830
  aiGenerate: 'Generate',
828
831
  aiGenerating: 'Thinking… (this spends tokens)',
829
832
  aiRebuild: 'Regenerate',
@@ -847,6 +850,7 @@ window.__ModuleLoader__.load({
847
850
  : `The last attempt failed: ${code}`,
848
851
  aiCacheMemory: 'Generated in this page; a view switch keeps it, a reload does not',
849
852
  aiStale: 'Out of date',
853
+ aiContextMoved: 'The conversation digest has moved on. This answer was generated from the earlier context; regenerating would include the new material.',
850
854
  aiStaleHead: 'HEAD moved on (the branch has new commits); this is the previous result',
851
855
  aiStaleContext: 'The conversation digest changed; this is the previous result',
852
856
  aiStaleBoth: 'HEAD and the conversation digest both moved; this is the previous result',
@@ -1642,6 +1646,10 @@ window.__ModuleLoader__.load({
1642
1646
  const [selected, setSelected] = useState(parsed?.path ?? null);
1643
1647
 
1644
1648
  const highlight = useHighlighter(selected ?? parsed?.path ?? '');
1649
+ /** The scrolling box, the row to reveal, and the hunk to fall back to. */
1650
+ const bodyRef = useRef(null);
1651
+ const targetRowRef = useRef(null);
1652
+ const nearestHunkRef = useRef(null);
1645
1653
  const scope = typeof params.scope === 'string' ? params.scope : 'unstaged';
1646
1654
  const cwd = typeof params.cwd === 'string' ? params.cwd : undefined;
1647
1655
  const seq = Number.isSafeInteger(params.seq) ? params.seq : undefined;
@@ -1684,6 +1692,24 @@ window.__ModuleLoader__.load({
1684
1692
 
1685
1693
  // The change set of the whole source, so the pane walks it file by file
1686
1694
  // instead of being one file's dead end.
1695
+ // A highlight the reader cannot see is not a jump. Scroll the pane itself —
1696
+ // never the page around it — so the target row lands near the middle.
1697
+ useEffect(() => {
1698
+ if (line === undefined) return undefined;
1699
+ const container = bodyRef.current;
1700
+ if (container === null || container === undefined) return undefined;
1701
+ const row = targetRowRef.current ?? nearestHunkRef.current;
1702
+ if (row === null || row === undefined) return undefined;
1703
+ const rowHeight = row.clientHeight ?? 0;
1704
+ const view = container.clientHeight ?? 0;
1705
+ const top = row.offsetTop ?? 0;
1706
+ const next = Math.max(0, top - Math.max(0, (view - rowHeight) / 2));
1707
+ // Assigning scrollTop moves this pane alone; scrollIntoView could also
1708
+ // move the conversation behind it.
1709
+ container.scrollTop = next;
1710
+ return undefined;
1711
+ }, [state.document, line, endLine, selected]);
1712
+
1687
1713
  // The column keeps one tab, so a new file arrives as a new navigation.
1688
1714
  useEffect(() => {
1689
1715
  if (typeof params.path === 'string' && params.path !== '') setSelected(params.path);
@@ -1876,6 +1902,7 @@ window.__ModuleLoader__.load({
1876
1902
  padding: '2px 8px',
1877
1903
  ...(hunkIndex === nearest
1878
1904
  ? {
1905
+ ref: nearestHunkRef,
1879
1906
  boxShadow:
1880
1907
  'inset 3px 0 0 0 var(--dsw-alias-border-focus, rgba(88,166,255,.95))',
1881
1908
  }
@@ -1897,6 +1924,7 @@ window.__ModuleLoader__.load({
1897
1924
  'div',
1898
1925
  {
1899
1926
  key: `line-${hunkIndex}-${lineIndex}`,
1927
+ ref: target ? targetRowRef : undefined,
1900
1928
  style: {
1901
1929
  display: 'flex',
1902
1930
  gap: 8,
@@ -1957,7 +1985,11 @@ window.__ModuleLoader__.load({
1957
1985
  );
1958
1986
  }
1959
1987
  }
1960
- body = h('div', { style: { overflow: 'auto', padding: '6px 0', flex: 1 } }, rows);
1988
+ body = h(
1989
+ 'div',
1990
+ { ref: bodyRef, style: { overflow: 'auto', padding: '6px 0', flex: 1 } },
1991
+ rows
1992
+ );
1961
1993
  }
1962
1994
 
1963
1995
  const rail =
@@ -2265,6 +2297,44 @@ window.__ModuleLoader__.load({
2265
2297
  * The view is rendered from a dictionary that a build or a locale can leave
2266
2298
  * incomplete; a missing plural helper must not empty the tab.
2267
2299
  */
2300
+ /**
2301
+ * The model a generation will use.
2302
+ *
2303
+ * The conversation owns the model, the plan's catalogue is the fallback, and
2304
+ * the plan reports the pair it resolved as a last resort. Anything else
2305
+ * returns `null` — so the caller must handle "no model yet" rather than
2306
+ * reading a property off nothing.
2307
+ */
2308
+ /**
2309
+ * The model name to print, never `undefined`.
2310
+ *
2311
+ * A result carries the model that answered it; an older entry may carry none,
2312
+ * in which case the name of the model that would answer now is the honest
2313
+ * thing to show, and the unknown copy is the fallback after that.
2314
+ */
2315
+ function modelNameText(answered, fallback) {
2316
+ if (typeof answered === 'string' && answered !== '') return answered;
2317
+ if (fallback !== null && fallback !== undefined && typeof fallback.model === 'string' && fallback.model !== '') {
2318
+ return `${fallback.provider ?? ''} ${fallback.model}`.trim();
2319
+ }
2320
+ return null;
2321
+ }
2322
+
2323
+ function modelForGeneration(sessionModel, plan) {
2324
+ const session = sessionModel ?? null;
2325
+ if (session !== null && typeof session.model === 'string' && session.model !== '') {
2326
+ return { provider: session.provider ?? null, model: session.model };
2327
+ }
2328
+ const first = plan?.models?.[0];
2329
+ if (first !== undefined && first !== null && typeof first.model === 'string' && first.model !== '') {
2330
+ return { provider: first.provider ?? null, model: first.model };
2331
+ }
2332
+ if (typeof plan?.plan?.model === 'string' && plan.plan.model !== '') {
2333
+ return { provider: plan.plan.provider ?? null, model: plan.plan.model };
2334
+ }
2335
+ return null;
2336
+ }
2337
+
2268
2338
  function copyText(t, key, ...args) {
2269
2339
  const value = t === null || t === undefined ? undefined : t[key];
2270
2340
  if (typeof value === 'function') return value(...args);
@@ -3080,6 +3150,12 @@ window.__ModuleLoader__.load({
3080
3150
  */
3081
3151
  const [zen, setZen] = useState(false);
3082
3152
  const [aiPlan, setAiPlan] = useState(null);
3153
+
3154
+ // The conversation area owns the model; this pane only falls back, and it
3155
+ // may legitimately have none yet (no selection event, plan still loading).
3156
+ // Declared here, before every consumer: a dependency array is evaluated
3157
+ // during render, so a later declaration is a temporal-dead-zone crash.
3158
+ const aiModel = useMemo(() => modelForGeneration(sessionModel, aiPlan), [sessionModel, aiPlan]);
3083
3159
  const [aiDocument, setAiDocument] = useState(null);
3084
3160
  const [aiError, setAiError] = useState(null);
3085
3161
  const [aiBusy, setAiBusy] = useState(false);
@@ -3092,11 +3168,13 @@ window.__ModuleLoader__.load({
3092
3168
  }, [aiFlow, aiDocument]);
3093
3169
  /** The pair a generation will use: the session's own, or a chosen one. */
3094
3170
  /** The conversation area's model; this pane never chooses its own. */
3095
- let aiModel = null;
3171
+ // derived below, once the session and the plan are both known
3096
3172
  /** Off by default: a record joins the model context on later turns. */
3097
3173
  const [aiRecord, setAiRecord] = useState(false);
3098
3174
  const [aiDebug, setAiDebug] = useState(false);
3099
3175
  const [aiStale, setAiStale] = useState(null);
3176
+ /** The digest moved, the code did not: a note, not an expiry. */
3177
+ const [aiContextMoved, setAiContextMoved] = useState(false);
3100
3178
  /** Reasoning strength, when the provider names its options. */
3101
3179
  const [aiEffort, setAiEffort] = useState('');
3102
3180
  const [selected, setSelected] = useState(null);
@@ -3257,6 +3335,13 @@ window.__ModuleLoader__.load({
3257
3335
  body.instruction = t.aiInstruction;
3258
3336
  if (typeof sessionId === 'string') body.sessionId = sessionId;
3259
3337
  }
3338
+ if (aiModel === null) {
3339
+ // Reading a property off nothing here is what broke Generate in a
3340
+ // real session: say what is missing instead.
3341
+ setAiError(copyText(t, 'aiNoModel'));
3342
+ setAiBusy(false);
3343
+ return;
3344
+ }
3260
3345
  const chosenProvider = aiModel.provider;
3261
3346
  const chosenModel = aiModel.model;
3262
3347
  if (chosenProvider !== undefined) body.provider = chosenProvider;
@@ -3336,7 +3421,10 @@ window.__ModuleLoader__.load({
3336
3421
  // An out-of-date answer is shown, not hidden: the reviewer may be
3337
3422
  // comparing against it, and only they decide whether to regenerate.
3338
3423
  if (value?.last?.document !== undefined && value.last.document !== null) {
3339
- setAiStale(value.last);
3424
+ // Only the code moving on marks the answer out of date; a changed
3425
+ // conversation digest is shown as a note, not as "expired".
3426
+ setAiStale(value.last.staleReason === null ? null : value.last);
3427
+ setAiContextMoved(value.last.contextMoved === true);
3340
3428
  setAiDocument((current) => current ?? value.last.document);
3341
3429
  }
3342
3430
  const held = flowClientCache.get(aiKey);
@@ -3357,16 +3445,6 @@ window.__ModuleLoader__.load({
3357
3445
  return () => controller.abort();
3358
3446
  }, [mode, root, source, baseRef, commitRef, sessionModel, conversation, aiPlan, aiKey, generateFlow]);
3359
3447
 
3360
- // The conversation area owns the model. This pane falls back to the first
3361
- // pair the profile advertises, so a first generation never fails for lack
3362
- // of one, and never offers a choice of its own.
3363
- const chosenModel =
3364
- sessionModel?.model !== undefined
3365
- ? sessionModel
3366
- : aiPlan?.models?.[0] !== undefined
3367
- ? { provider: aiPlan.models[0].provider, model: aiPlan.models[0].model }
3368
- : {};
3369
- aiModel = chosenModel.model === undefined ? null : chosenModel;
3370
3448
 
3371
3449
 
3372
3450
  /** Open the review pane at the file and line one AI node was based on. */
@@ -3377,8 +3455,14 @@ window.__ModuleLoader__.load({
3377
3455
  return;
3378
3456
  }
3379
3457
  setOpenError(null);
3380
- const anchor = (node.anchors ?? [])[0];
3381
- const path = anchor?.path ?? (node.files ?? [])[0];
3458
+ // A business step belongs in the code that implements it. A model that
3459
+ // anchored a step to a paragraph of documentation gave a target the
3460
+ // reader cannot use, so a source file wins whenever one is offered —
3461
+ // the document stays as the last resort rather than as the destination.
3462
+ const anchors = Array.isArray(node.anchors) ? node.anchors : [];
3463
+ const anchor = anchors.find((candidate) => isSourcePath(candidate?.path)) ?? anchors[0];
3464
+ const files = Array.isArray(node.files) ? node.files : [];
3465
+ const path = anchor?.path ?? files.find((candidate) => isSourcePath(candidate)) ?? files[0];
3382
3466
  const scope =
3383
3467
  scopeSpecFor(source, { base: baseRef, commit: commitRef }) ?? 'unstaged';
3384
3468
  const base = { cwd: root, scope };
@@ -3832,14 +3916,17 @@ window.__ModuleLoader__.load({
3832
3916
  aiBusy ? t.aiGenerating : aiDocument === null ? t.aiGenerate : t.aiRebuild),
3833
3917
  aiDocument !== null
3834
3918
  ? h('span', { style: { fontSize: 11, opacity: 0.7 } },
3835
- [t.aiModel(aiDocument.model), aiDocument.usage?.outputTokens !== undefined ? `${aiDocument.usage.outputTokens} tok` : null,
3919
+ [modelNameText(aiDocument.model, aiModel) === null
3920
+ ? t.aiModelUnknown
3921
+ : t.aiModel(modelNameText(aiDocument.model, aiModel)),
3922
+ aiDocument.usage?.outputTokens !== undefined ? `${aiDocument.usage.outputTokens} tok` : null,
3836
3923
  aiDocument.cached === true ? t.aiCached : null,
3837
3924
  typeof aiDocument.generatedAt === 'string' ? t.aiGeneratedAt(aiDocument.generatedAt.slice(0, 19).replace('T', ' ')) : null]
3838
3925
  .filter(Boolean).join(' · '))
3839
3926
  : h('span', { style: { fontSize: 11, opacity: 0.7 } },
3840
- typeof chosenModel.model === 'string'
3841
- ? t.aiModel(`${chosenModel.provider ?? ''} ${chosenModel.model}`.trim())
3842
- : t.aiModelUnknown),
3927
+ aiModel === null
3928
+ ? t.aiModelUnknown
3929
+ : t.aiModel(`${aiModel.provider ?? ''} ${aiModel.model}`.trim())),
3843
3930
  h(
3844
3931
  'label',
3845
3932
  {
@@ -3895,6 +3982,13 @@ window.__ModuleLoader__.load({
3895
3982
  : null,
3896
3983
  // Saying which cache an answer would come from is the difference
3897
3984
  // between "it worked" and "I cannot tell".
3985
+ aiContextMoved && aiStale === null
3986
+ ? h(
3987
+ 'div',
3988
+ { style: { fontSize: 11, opacity: 0.7 } },
3989
+ t.aiContextMoved
3990
+ )
3991
+ : null,
3898
3992
  aiStale !== null
3899
3993
  ? h(
3900
3994
  'div',
@@ -4657,6 +4751,9 @@ window.__ModuleLoader__.load({
4657
4751
  ReviewGraphView,
4658
4752
  ReviewGraphBody,
4659
4753
  layoutGraph,
4754
+ modelForGeneration,
4755
+ modelNameText,
4756
+ isSourcePath,
4660
4757
  analyze,
4661
4758
  unwrapRemote,
4662
4759
  listSourceFiles,
package/index.js CHANGED
@@ -1108,14 +1108,13 @@ export function registerFlowRoute(ctx, exec, recorder, options = {}) {
1108
1108
  return [];
1109
1109
  }
1110
1110
  })();
1111
+ // Out of date means the *code* moved on. The conversation digest is a
1112
+ // different thing: it changes whenever the session grows — including
1113
+ // when a generation records itself into the conversation — and telling
1114
+ // someone their answer is stale because they kept talking is wrong.
1111
1115
  const staleReason =
1112
- stored === undefined || fresh
1113
- ? null
1114
- : stored.head !== material.head && stored.contextHash !== contextHash
1115
- ? 'both'
1116
- : stored.head !== material.head
1117
- ? 'head'
1118
- : 'context';
1116
+ stored === undefined || stored.head === material.head ? null : 'head';
1117
+ const contextMoved = stored !== undefined && stored.contextHash !== contextHash;
1119
1118
  return Response.json(
1120
1119
  {
1121
1120
  diskCached: stored !== undefined,
@@ -1132,6 +1131,9 @@ export function registerFlowRoute(ctx, exec, recorder, options = {}) {
1132
1131
  generatedAt: stored.generatedAt,
1133
1132
  head: stored.head,
1134
1133
  staleReason,
1134
+ // Reported, not treated as staleness: a new generation
1135
+ // would read this, the previous answer did not.
1136
+ contextMoved,
1135
1137
  },
1136
1138
  plan: {
1137
1139
  scope: material.graph.git?.label ?? spec.kind,
@@ -1188,7 +1190,9 @@ export function registerFlowRoute(ctx, exec, recorder, options = {}) {
1188
1190
  {
1189
1191
  ...stored.document,
1190
1192
  cached: true,
1191
- model: stored.model,
1193
+ // An entry stored before this was normalised has no model; report
1194
+ // the one this request resolved rather than nothing.
1195
+ model: stored.model ?? model ?? null,
1192
1196
  provider: stored.provider,
1193
1197
  usage: stored.usage,
1194
1198
  generatedAt: stored.generatedAt,
@@ -1286,7 +1290,9 @@ export function registerFlowRoute(ctx, exec, recorder, options = {}) {
1286
1290
  };
1287
1291
  const entry = {
1288
1292
  document,
1289
- model,
1293
+ // Normalised, both of them: `undefined` is dropped by JSON.stringify,
1294
+ // so the stored document would come back without a model at all.
1295
+ model: model ?? null,
1290
1296
  provider: provider ?? null,
1291
1297
  usage: usageNumbers(collected.usage),
1292
1298
  generatedAt: new Date().toISOString(),
@@ -1323,7 +1329,7 @@ export function registerFlowRoute(ctx, exec, recorder, options = {}) {
1323
1329
  {
1324
1330
  ...document,
1325
1331
  cached: false,
1326
- model,
1332
+ model: model ?? null,
1327
1333
  provider: entry.provider,
1328
1334
  usage: entry.usage,
1329
1335
  generatedAt: entry.generatedAt,
package/lib/flow.mjs CHANGED
@@ -241,6 +241,7 @@ export function flowPrompt(input) {
241
241
  'Every node MUST list at least one file it is based on in "files", and add an "anchors" entry with that file and the line you mean whenever the material shows one — a node without a file cannot be opened by the reviewer.',
242
242
  'An anchor covers a range when the step does: give "line" and "endLine" (inclusive) for a block of lines, and only "line" for one line. Do not collapse a range to its first line.',
243
243
  'Anchor lines are lines of the file on the side the diagram shows: the "before" side names original lines, the "after" side names the changed lines.',
244
+ 'Anchor a step to the code that implements it. Do not send the reader to a document, a comment file, or a configuration file for a step about behaviour; name such a file only when the step itself is about that document, and say so in the detail.',
244
245
  'If the material does not support a step or an edge, omit it, or mark the node "confidence": "low" — never invent behaviour.',
245
246
  'When conversation context is given, use it to name the business intent and the people or systems involved — but the code is the authority on what actually changed; if the two disagree, follow the code.',
246
247
  'Do not read the excerpts exhaustively and do not restate them: decide the flows from the file list, the symbols, and the references, then answer.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-review-graph",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "type": "module",
5
5
  "description": "Review a change set as a relationship graph in the middle column, with every node clickable to the exact source line.",
6
6
  "exports": {
@@ -24,7 +24,8 @@
24
24
  "README.md",
25
25
  "docs",
26
26
  "CONTRIBUTING.md",
27
- "README.en.md"
27
+ "README.en.md",
28
+ "CHANGELOG.md"
28
29
  ],
29
30
  "icon": "./icon.svg",
30
31
  "meta": {