dsh-review-graph 1.0.0 → 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 +30 -0
- package/CONTRIBUTING.md +20 -3
- package/README.en.md +7 -7
- package/README.md +7 -7
- package/client.js +116 -19
- package/index.js +16 -10
- package/lib/flow.mjs +1 -0
- package/package.json +3 -2
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/CONTRIBUTING.md
CHANGED
|
@@ -41,15 +41,32 @@ package does:
|
|
|
41
41
|
npm publish --otp=<code from the authenticator>
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
After that, configure **Trusted publishing** on the package's settings page
|
|
45
|
-
`huaxiaolong/dsh-review-graph`, workflow `publish.yml
|
|
46
|
-
rest
|
|
44
|
+
After that, configure **Trusted publishing** on the package's settings page — publisher *GitHub Actions*,
|
|
45
|
+
repository `huaxiaolong/dsh-review-graph`, workflow file name `publish.yml`, environment name left empty —
|
|
46
|
+
and let `.github/workflows/publish.yml` do the rest. It needs no stored secret and publishes with
|
|
47
|
+
provenance. Two details decide whether it works: the workflow must ask for `id-token: write` (it does), and
|
|
48
|
+
the npm CLI must be 11.5.1 or newer — Node 20's bundled npm is older, so the workflow installs `npm@11`
|
|
49
|
+
first. Pin the major rather than using `npm@latest`: npm 12 requires Node 22.22.2 or newer, and asking for
|
|
50
|
+
it on an older runner fails the install with an engine error before the build even starts.
|
|
47
51
|
|
|
48
52
|
```bash
|
|
49
53
|
# bump version in package.json, commit, push
|
|
50
54
|
git tag v<version> && git push origin v<version>
|
|
51
55
|
```
|
|
52
56
|
|
|
57
|
+
### Release artifacts and their hashes
|
|
58
|
+
|
|
59
|
+
Publish the artifact `npm pack` produced — not a tarball assembled by hand. `npm pack` normalises file
|
|
60
|
+
mtimes and entry order, so the same sources produce the same bytes; a `tar -czf` of the same files does
|
|
61
|
+
not, and the registry's `shasum` will not match anything you can reproduce. If a GitHub release carries a
|
|
62
|
+
tarball, attach the one `npm pack` wrote, so the two channels share one hash.
|
|
63
|
+
|
|
64
|
+
Check the published one with:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm view dsh-review-graph dist.shasum dist.tarball
|
|
68
|
+
```
|
|
69
|
+
|
|
53
70
|
Why OIDC rather than an `NPM_TOKEN`: npm is removing direct publish from tokens that bypass 2FA
|
|
54
71
|
(targeted for January 2027), so a stored token would stop working. Trusted publishing replaces it with a
|
|
55
72
|
short-lived credential minted per run.
|
package/README.en.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-review-graph
|
|
2
2
|
|
|
3
|
-
[中文](README.md) · English
|
|
3
|
+
[中文](README.md) · English · [](https://www.npmjs.com/package/dsh-review-graph)
|
|
4
4
|
|
|
5
5
|
**Which files call which, how far a change reaches, and where every edit actually is** — read it inside
|
|
6
6
|
the conversation instead of opening files one by one.
|
|
@@ -21,14 +21,14 @@ Three surfaces, in the conversation's middle column:
|
|
|
21
21
|
Requirements: DSH (Host and Web), and the workspace you review is a **git repository**.
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
# 1) from
|
|
25
|
-
dsh plugin --profile <profile> add
|
|
24
|
+
# 1) from npm (recommended)
|
|
25
|
+
dsh plugin --profile <profile> add dsh-review-graph
|
|
26
26
|
|
|
27
|
-
# 2) from
|
|
28
|
-
dsh plugin --profile <profile> add https://github.com/huaxiaolong/dsh-review-graph
|
|
27
|
+
# 2) from git (no account needed)
|
|
28
|
+
dsh plugin --profile <profile> add https://github.com/huaxiaolong/dsh-review-graph
|
|
29
29
|
|
|
30
|
-
# 3) from npm
|
|
31
|
-
dsh plugin --profile <profile> add dsh-review-graph
|
|
30
|
+
# 3) from the release tarball (no npm account needed; pins a version)
|
|
31
|
+
dsh plugin --profile <profile> add https://github.com/huaxiaolong/dsh-review-graph/releases/download/v1.0.0/dsh-review-graph-1.0.0.tgz
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Or in the GUI: **Plugins** in the sidebar → **Add plugin** → paste any of the above (package name, git
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-review-graph
|
|
2
2
|
|
|
3
|
-
[English](README.en.md) · 中文
|
|
3
|
+
[English](README.en.md) · 中文 · [](https://www.npmjs.com/package/dsh-review-graph)
|
|
4
4
|
|
|
5
5
|
一次变更里**哪些文件互相调用、影响面多大、每处改动到底在哪一行**——在会话里直接看懂,不用在编辑器里一个个翻。
|
|
6
6
|
|
|
@@ -20,14 +20,14 @@
|
|
|
20
20
|
要求:DSH(Host + Web 端),被审查的工作区是 **git 仓库**。
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
# 1) 从
|
|
24
|
-
dsh plugin --profile <profile> add
|
|
23
|
+
# 1) 从 npm(推荐)
|
|
24
|
+
dsh plugin --profile <profile> add dsh-review-graph
|
|
25
25
|
|
|
26
|
-
# 2) 从
|
|
27
|
-
dsh plugin --profile <profile> add https://github.com/huaxiaolong/dsh-review-graph
|
|
26
|
+
# 2) 从 git(无需任何账号)
|
|
27
|
+
dsh plugin --profile <profile> add https://github.com/huaxiaolong/dsh-review-graph
|
|
28
28
|
|
|
29
|
-
# 3) 从 npm
|
|
30
|
-
dsh plugin --profile <profile> add dsh-review-graph
|
|
29
|
+
# 3) 从 Release 里的 tarball(无需 npm 账号,可钉死版本)
|
|
30
|
+
dsh plugin --profile <profile> add https://github.com/huaxiaolong/dsh-review-graph/releases/download/v1.0.0/dsh-review-graph-1.0.0.tgz
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
或在 GUI 里:侧栏「**插件**」→ **Add plugin** → 粘贴上面任意一种(包名、git 地址、tarball 地址或本地路径)。
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3381
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
3841
|
-
? t.
|
|
3842
|
-
: t.
|
|
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 ||
|
|
1113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|