dsh-better-display 0.3.2 → 0.3.5
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 +19 -0
- package/README.en.md +13 -15
- package/README.md +13 -15
- package/compat/harness-rc2.json +11 -9
- package/docs/official-rendering-bridge.md +1 -1
- package/lib/client.js +829 -2680
- package/lib/client.js.map +1 -1
- package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
- package/lib/types/client/ChoreographedFlow.js +6 -0
- package/lib/types/client/ChoreographedFlow.js.map +1 -1
- package/lib/types/client/Reader.js +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/markdown/render.d.ts.map +1 -1
- package/lib/types/client/markdown/render.js +2 -1
- package/lib/types/client/markdown/render.js.map +1 -1
- package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -1
- package/lib/types/client/native/ContextInjectionRow.js +15 -3
- package/lib/types/client/native/ContextInjectionRow.js.map +1 -1
- package/lib/types/client/native/tool-update.d.ts +23 -0
- package/lib/types/client/native/tool-update.d.ts.map +1 -0
- package/lib/types/client/native/tool-update.js +22 -0
- package/lib/types/client/native/tool-update.js.map +1 -0
- package/lib/types/client/platform-media.d.ts +1 -1
- package/lib/types/client/platform-media.d.ts.map +1 -1
- package/lib/types/client/platform-media.js +9 -4
- package/lib/types/client/platform-media.js.map +1 -1
- package/package.json +22 -17
- package/scripts/build-motion-fixture.mjs +12 -0
- package/scripts/link-harness-dependencies.mjs +2 -0
- package/scripts/verify-flowcell.mjs +87 -0
- package/scripts/verify-tool-shimmer.mjs +62 -0
- package/src/client/ChoreographedFlow.tsx +5 -0
- package/src/client/Reader.module.css +5 -2
- package/src/client/Reader.tsx +1 -1
- package/src/client/index.tsx +2 -0
- package/src/client/markdown/render.tsx +2 -1
- package/src/client/native/ContextInjectionRow.module.css +4 -0
- package/src/client/native/ContextInjectionRow.tsx +28 -6
- package/src/client/native/tool-update.ts +32 -0
- package/src/client/platform-media.ts +9 -3
- package/tests/context-tool-update.test.ts +33 -0
- package/tests/platform-media.test.ts +6 -0
- package/tests/stock-install.test.ts +17 -0
- package/tsdown.config.ts +7 -5
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Actual FlowCell browser regression. Use --baseline for the v0.3.4 A/B reproduction. */
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
const { launchPinnedChromium } = await import(pathToFileURL(join(homedir(), '.codex/playwright-runtime/runtime.mjs')).href);
|
|
11
|
+
|
|
12
|
+
const root = resolve(import.meta.dirname, '..');
|
|
13
|
+
const out = join(root, '.evidence/flowcell');
|
|
14
|
+
const require = createRequire(join(root, 'package.json'));
|
|
15
|
+
const harness = process.env.DSHX_HARNESS;
|
|
16
|
+
if (!harness) throw new Error('Set DSHX_HARNESS to the target checkout.');
|
|
17
|
+
const webRequire = createRequire(join(harness, 'packages/client/web/package.json'));
|
|
18
|
+
const variants = process.argv.includes('--baseline') ? ['baseline', 'fixed'] : ['fixed'];
|
|
19
|
+
const { build } = await import(pathToFileURL(createRequire(require.resolve('tsx')).resolve('esbuild')).href);
|
|
20
|
+
await mkdir(out, { recursive: true });
|
|
21
|
+
const fixture = `import React, {useState} from 'react';
|
|
22
|
+
import {createRoot} from 'react-dom/client';
|
|
23
|
+
import {ReviewFlowCell} from './src/client/ChoreographedFlow.tsx';
|
|
24
|
+
function Demo(){
|
|
25
|
+
const [config,setConfig]=useState({hidden:true,instant:false,motion:true,height:160});
|
|
26
|
+
window.review={set:patch=>setConfig(previous=>({...previous,...patch}))};
|
|
27
|
+
return <ReviewFlowCell {...config} rowKey="probe"><div data-review-content style={{height:config.height,background:'#dae7f5'}}>FlowCell content</div></ReviewFlowCell>;
|
|
28
|
+
}
|
|
29
|
+
createRoot(document.getElementById('app')).render(<Demo/>);`;
|
|
30
|
+
for (const variant of variants) {
|
|
31
|
+
const source = variant === 'baseline'
|
|
32
|
+
? execFileSync('git', ['show', 'v0.3.4:src/client/ChoreographedFlow.tsx'], { cwd: root, encoding: 'utf8' })
|
|
33
|
+
: await readFile(join(root, 'src/client/ChoreographedFlow.tsx'), 'utf8');
|
|
34
|
+
await build({
|
|
35
|
+
stdin: { contents: fixture, resolveDir: root, loader: 'tsx' },
|
|
36
|
+
outfile: join(out, `${variant}.js`), bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
|
|
37
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
38
|
+
alias: { react: dirname(require.resolve('react/package.json')), 'react-dom': dirname(webRequire.resolve('react-dom/package.json')) },
|
|
39
|
+
loader: { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.svg': 'dataurl' },
|
|
40
|
+
plugins: [{ name: 'expose-exact-flowcell-for-regression', setup(builder) {
|
|
41
|
+
builder.onLoad({ filter: /ChoreographedFlow\.tsx$/ }, () => ({ contents: source + '\nexport {FlowCell as ReviewFlowCell};', loader: 'tsx', resolveDir: join(root, 'src/client') }));
|
|
42
|
+
}}],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const server = createServer(async (req, res) => {
|
|
46
|
+
const match = /^\/(baseline|fixed)\.(js|css)$/.exec(req.url);
|
|
47
|
+
if (match) {res.setHeader('Content-Type', match[2] === 'js' ? 'text/javascript' : 'text/css');res.end(await readFile(join(out, match[0].slice(1))));return;}
|
|
48
|
+
const variant = req.url === '/baseline' ? 'baseline' : 'fixed';
|
|
49
|
+
res.setHeader('Content-Type', 'text/html');
|
|
50
|
+
res.end(`<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="/${variant}.css"><style>body{margin:24px;font:16px system-ui}#app{width:600px}</style><div id="app"></div><script type="module" src="/${variant}.js"></script>`);
|
|
51
|
+
});
|
|
52
|
+
await new Promise(done => server.listen(0, '127.0.0.1', done));
|
|
53
|
+
const browser = await launchPinnedChromium();
|
|
54
|
+
const results = [];
|
|
55
|
+
try {
|
|
56
|
+
for (const variant of variants) {
|
|
57
|
+
const page = await browser.newPage({viewport:{width:800,height:650}});
|
|
58
|
+
const errors=[]; page.on('pageerror',e=>errors.push(String(e)));
|
|
59
|
+
await page.goto(`http://127.0.0.1:${server.address().port}/${variant}`);
|
|
60
|
+
await page.waitForFunction(()=>!!window.review);
|
|
61
|
+
const set = async patch => {await page.evaluate(patch=>window.review.set(patch),patch);await page.waitForTimeout(600);};
|
|
62
|
+
const probe = () => page.locator('[data-flow-key="probe"]').evaluate(el=>({height:el.getBoundingClientRect().height,inner:el.firstElementChild?.getBoundingClientRect().height??0,animations:el.getAnimations().length,hidden:el.hidden}));
|
|
63
|
+
const checkpoints=[];
|
|
64
|
+
await set({hidden:false}); checkpoints.push({step:'expand',...await probe()});
|
|
65
|
+
await set({height:40}); checkpoints.push({step:'content-shrink',...await probe()});
|
|
66
|
+
await page.screenshot({path:join(out,`${variant}-shrink.png`)});
|
|
67
|
+
await set({height:300}); checkpoints.push({step:'content-grow',...await probe()});
|
|
68
|
+
await set({hidden:true}); checkpoints.push({step:'collapse',...await probe()});
|
|
69
|
+
for(let i=0;i<3;i++){await set({hidden:false});checkpoints.push({step:`expand-cycle-${i+1}`,...await probe()});await set({hidden:true});checkpoints.push({step:`collapse-cycle-${i+1}`,...await probe()});}
|
|
70
|
+
await set({hidden:false});await set({instant:true,hidden:true});checkpoints.push({step:'instant-collapse',...await probe()});
|
|
71
|
+
await set({instant:false,motion:false,hidden:false});await set({height:80});checkpoints.push({step:'motion-disabled',...await probe()});
|
|
72
|
+
await set({motion:true,hidden:true});
|
|
73
|
+
await page.evaluate(()=>{window.review.set({hidden:false});setTimeout(()=>window.review.set({hidden:true}),40);setTimeout(()=>window.review.set({hidden:false}),80);});
|
|
74
|
+
await page.waitForTimeout(800);checkpoints.push({step:'rapid-reversal',...await probe()});
|
|
75
|
+
assert.deepEqual(errors,[]);
|
|
76
|
+
if(variant==='baseline'){
|
|
77
|
+
const shrunk=checkpoints.find(x=>x.step==='content-shrink');
|
|
78
|
+
assert.ok(shrunk.animations>0&&shrunk.height-shrunk.inner>100,'Baseline must reproduce stale fill and blank region');
|
|
79
|
+
}else{
|
|
80
|
+
for(const x of checkpoints){assert.equal(x.animations,0,`${x.step}: animation retained`);assert.ok(Math.abs(x.height-x.inner)<1,`${x.step}: incorrect geometry ${JSON.stringify(x)}`);}
|
|
81
|
+
}
|
|
82
|
+
results.push({variant,checkpoints,errors});await page.close();
|
|
83
|
+
}
|
|
84
|
+
const result={passed:true,baseline:variants.includes('baseline')?'v0.3.4':null,scope:'Actual FlowCell component, synthetic content; isolated pinned Chromium',results};
|
|
85
|
+
await writeFile(join(out,'result.json'),JSON.stringify(result,null,2)+'\n');
|
|
86
|
+
console.log(JSON.stringify(result));
|
|
87
|
+
}finally{await browser.close();await new Promise(done=>server.close(done));}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Offline pixel regression for the shipped tool-summary CSS; no Host or model. */
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const root = resolve(import.meta.dirname, '..');
|
|
9
|
+
const bundle = resolve(process.argv[2] ?? join(root, 'lib/client.js'));
|
|
10
|
+
const out = join(root, '.evidence/tool-shimmer');
|
|
11
|
+
await mkdir(out, { recursive: true });
|
|
12
|
+
const js = await readFile(bundle, 'utf8');
|
|
13
|
+
const line = js.split('\n').find(line => /const css\$\d+ = "\.\w+_root\{/.test(line));
|
|
14
|
+
assert.ok(line, 'Reader CSS must be present in the shipped bundle');
|
|
15
|
+
const css = JSON.parse(line.slice(line.indexOf('=') + 1).trim().replace(/;$/, ''));
|
|
16
|
+
const summary = css.match(/\.(\w+_nativeToolSummary)\[data-running\]/)?.[1];
|
|
17
|
+
assert.ok(summary);
|
|
18
|
+
const { launchPinnedChromium } = await import(pathToFileURL(join(homedir(), '.codex/playwright-runtime/runtime.mjs')).href);
|
|
19
|
+
const browser = await launchPinnedChromium();
|
|
20
|
+
const results = [];
|
|
21
|
+
try {
|
|
22
|
+
for (const theme of ['dark', 'light']) for (const width of [180, 800]) {
|
|
23
|
+
const page = await browser.newPage({ viewport: { width: 900, height: 120 }, deviceScaleFactor: 2 });
|
|
24
|
+
await page.setContent(`<style>${css}body{margin:20px;background:${theme === 'dark' ? '#141414' : '#fff'};--dsw-alias-label-tertiary:${theme === 'dark' ? '#aaa' : '#666'};font:14px system-ui}.row{display:flex;width:${width}px}</style><div class="row" data-motion="on"><span data-running class="${summary}">Re-run acceptance criteria verification</span></div>`);
|
|
25
|
+
const target = page.locator(`.${summary}`);
|
|
26
|
+
const capture = async time => {
|
|
27
|
+
await target.evaluate((el, time) => {
|
|
28
|
+
const animations = el.getAnimations();
|
|
29
|
+
if (animations.length !== 1) throw new Error(`Expected one animation, found ${animations.length}`);
|
|
30
|
+
animations[0].pause(); animations[0].currentTime = time;
|
|
31
|
+
}, time);
|
|
32
|
+
return target.screenshot();
|
|
33
|
+
};
|
|
34
|
+
const timing = await target.evaluate(el => el.getAnimations()[0].effect.getTiming().duration);
|
|
35
|
+
const first = await capture(0);
|
|
36
|
+
const middle = await capture(timing * .25);
|
|
37
|
+
const beforeWrap = await capture(timing - 1);
|
|
38
|
+
const afterWrap = await capture(timing);
|
|
39
|
+
assert.notDeepEqual(middle, first, 'The highlight must visibly travel across the text');
|
|
40
|
+
assert.deepEqual(beforeWrap, first, 'The sweep must fully leave the text before wrapping');
|
|
41
|
+
assert.deepEqual(afterWrap, first, 'The cycle boundary must preserve identical text pixels');
|
|
42
|
+
await writeFile(join(out, `${theme}-${width}-start.png`), first);
|
|
43
|
+
await writeFile(join(out, `${theme}-${width}-middle.png`), middle);
|
|
44
|
+
await writeFile(join(out, `${theme}-${width}-end.png`), beforeWrap);
|
|
45
|
+
const runningStyle = await target.evaluate(el => ({ repeat: getComputedStyle(el).backgroundRepeat, animationCount: el.getAnimations().length }));
|
|
46
|
+
assert.equal(runningStyle.repeat, 'no-repeat');
|
|
47
|
+
await page.locator('[data-motion]').evaluate(el => { el.dataset.motion = 'off'; });
|
|
48
|
+
assert.equal(await target.evaluate(el => el.getAnimations().length), 0, 'Reader motion-off must stop shimmer');
|
|
49
|
+
await page.locator('[data-motion]').evaluate(el => { el.dataset.motion = 'on'; });
|
|
50
|
+
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
51
|
+
const reduced = await target.evaluate(el => ({ count: el.getAnimations().length, fill: getComputedStyle(el).webkitTextFillColor }));
|
|
52
|
+
assert.equal(reduced.count, 0, 'System reduced motion must stop shimmer');
|
|
53
|
+
assert.notEqual(reduced.fill, 'rgba(0, 0, 0, 0)', 'Stopped animation must keep text readable');
|
|
54
|
+
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
|
55
|
+
await target.evaluate(el => el.removeAttribute('data-running'));
|
|
56
|
+
assert.equal(await target.evaluate(el => el.getAnimations().length), 0, 'Settled tools must not shimmer');
|
|
57
|
+
results.push({ theme, width, loopPixelsContinuous: true, ...runningStyle, motionOff: true, reducedMotion: true, settled: true });
|
|
58
|
+
await page.close();
|
|
59
|
+
}
|
|
60
|
+
await writeFile(join(out, 'result.json'), JSON.stringify({ bundle, results }, null, 2));
|
|
61
|
+
console.log(JSON.stringify({ passed: true, results }));
|
|
62
|
+
} finally { await browser.close(); }
|
|
@@ -70,6 +70,11 @@ function FlowCell({ hidden, instant, motion, rowKey, children, summary = false }
|
|
|
70
70
|
const settle = () => {
|
|
71
71
|
if (settled) return;
|
|
72
72
|
settled = true;
|
|
73
|
+
// Expanding retires nothing: the fill only pins the measured height. Drop it
|
|
74
|
+
// now — if the cell's content later shrinks, the stale fill would hold the
|
|
75
|
+
// empty space open. Collapsing still keeps the fill until React commits
|
|
76
|
+
// the removal (see the comment above), then the cleanup cancels it.
|
|
77
|
+
if (!hidden) animation.cancel();
|
|
73
78
|
setPresent(!hidden);
|
|
74
79
|
release();
|
|
75
80
|
};
|
|
@@ -250,13 +250,16 @@
|
|
|
250
250
|
currentColor 0%, currentColor 35%,
|
|
251
251
|
color-mix(in srgb, currentColor 35%, transparent) 50%,
|
|
252
252
|
currentColor 65%, currentColor 100%);
|
|
253
|
-
|
|
253
|
+
/* Keep the travelling band outside the text at both loop endpoints. A single
|
|
254
|
+
tile covers the row throughout, so wrapping cannot start a second sweep. */
|
|
255
|
+
background-size: 300% 100%;
|
|
256
|
+
background-repeat: no-repeat;
|
|
254
257
|
background-position: 100% 0;
|
|
255
258
|
-webkit-background-clip: text; background-clip: text;
|
|
256
259
|
-webkit-text-fill-color: transparent;
|
|
257
260
|
animation: readerStepShimmer 1.6s linear infinite;
|
|
258
261
|
}
|
|
259
|
-
@keyframes readerStepShimmer { to { background-position:
|
|
262
|
+
@keyframes readerStepShimmer { to { background-position: 0% 0; } }
|
|
260
263
|
/* The duration stays on a step that ran long, so a slow command keeps its number;
|
|
261
264
|
a fast one shows nothing once it returns. */
|
|
262
265
|
.toolElapsed[data-slow] { opacity: 1; font-weight: 500;
|
package/src/client/Reader.tsx
CHANGED
|
@@ -815,7 +815,7 @@ export function Reader(props: ReaderProps) {
|
|
|
815
815
|
|
|
816
816
|
// ChatView publishes data-chat-flow="" on its column. Skins treat a
|
|
817
817
|
// scrollport without that hook as inspect-only and hide [data-composer-seat].
|
|
818
|
-
return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-reader-build="0.3.
|
|
818
|
+
return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-reader-build="0.3.4" data-dsh-better-display="0.3.4" data-reader-wait-clock-version="input-v1" data-reader-wait-start={waitAnchor.time ?? undefined} data-motion={motion ? 'on' : 'off'} data-reader-glass={frostedGlass || undefined} data-reader-auto-fold={autoFold ? 'on' : 'off'}>
|
|
819
819
|
<TimelineRail items={timelineItems} activeTurn={activeTurn} busyTurn={busyTurn} onNavigate={onNavigateTurn} />
|
|
820
820
|
<div className={css.column} data-chat-flow="">
|
|
821
821
|
<StickyLane kind="toolbar" className={css.toolbar}>
|
package/src/client/index.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type {} from '@deepseek-ai/dsh-api-remotes/client';
|
|
2
3
|
import type {} from '@deepseek-ai/dsh-api-session-controller/client';
|
|
4
|
+
import type {} from '@deepseek-ai/dsh-client-connection/client';
|
|
3
5
|
import type { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
4
6
|
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path';
|
|
5
7
|
import * as workspacePathPkg from '@deepseek-ai/dsh-util-workspace-path';
|
|
@@ -524,7 +524,8 @@ function renderImage(url: string, alt: string, key: Key, context: MarkdownRender
|
|
|
524
524
|
let imageSrc = remote
|
|
525
525
|
if (rewritten !== undefined) {
|
|
526
526
|
try {
|
|
527
|
-
|
|
527
|
+
const parsed = new URL(rewritten)
|
|
528
|
+
if (['http:', 'https:', 'blob:', 'data:'].includes(parsed.protocol) || rewritten.startsWith('dsh-app://app/api/file?')) imageSrc = rewritten
|
|
528
529
|
} catch { /* A vocabulary result must be an absolute URL. */ }
|
|
529
530
|
}
|
|
530
531
|
if (imageSrc === undefined) {
|
|
@@ -4,6 +4,7 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
4
4
|
import { DisclosureRow, IconBrowseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
5
5
|
import { ReferenceIcon } from './ReferenceIcon.js'
|
|
6
6
|
import { contextBody } from './ContextBody.js'
|
|
7
|
+
import { toolUpdateRows } from './tool-update.js'
|
|
7
8
|
import css from './ContextInjectionRow.module.css'
|
|
8
9
|
|
|
9
10
|
/** Props for the logged non-user message presentation. */
|
|
@@ -46,16 +47,32 @@ export function ContextInjectionRow({ content, source, provenance, producer, for
|
|
|
46
47
|
// Resolved rather than declared: a form whose fields are unreadable renders
|
|
47
48
|
// the opaque body, and the marker must say what the row actually shows.
|
|
48
49
|
const { rendered, summary, body } = contextBody(form, { content, source, t })
|
|
50
|
+
const tools = toolUpdateRows(content)
|
|
51
|
+
const toolSummary = tools === null || tools.single !== null ? null
|
|
52
|
+
: tools.added.length > 0 && tools.removed.length > 0
|
|
53
|
+
? t('message.toolsChanged', { added: tools.added.length, removed: tools.removed.length })
|
|
54
|
+
: tools.added.length > 0
|
|
55
|
+
? t('message.toolsAddedCount', { count: tools.added.length })
|
|
56
|
+
: t('message.toolsRemovedCount', { count: tools.removed.length })
|
|
49
57
|
|
|
50
58
|
return (
|
|
51
59
|
<DisclosureRow
|
|
52
60
|
className={css.root}
|
|
53
|
-
icon={view.role === 'recall'
|
|
61
|
+
icon={view.role === 'recall' && tools === null
|
|
54
62
|
? <span data-context-recall-icon><ReferenceIcon kind="session" /></span>
|
|
55
63
|
: <IconBrowseOutlineRegular size={14} />}
|
|
56
64
|
chevronClassName={css.chevron}
|
|
57
|
-
title={
|
|
58
|
-
|
|
65
|
+
title={tools?.single !== undefined && tools.single !== null
|
|
66
|
+
? t(tools.single.type === 'tool-addition' ? 'message.toolAdded' : 'message.toolRemoved', { name: tools.single.toolName })
|
|
67
|
+
: tools !== null
|
|
68
|
+
? t('message.toolsUpdated')
|
|
69
|
+
: t(view.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
|
|
70
|
+
collapsedContent={toolSummary !== null ? (
|
|
71
|
+
<>
|
|
72
|
+
<span className={css.sep} aria-hidden />
|
|
73
|
+
<span className={css.summary}>{toolSummary}</span>
|
|
74
|
+
</>
|
|
75
|
+
) : tools !== null || view.label === null ? undefined : (
|
|
59
76
|
/* ToolRow's separator shape: an aria-hidden dot, so the accessible name
|
|
60
77
|
stays the two readable parts and the two disclosure rows expose one
|
|
61
78
|
name shape. A source that names no producer drops the dot with it. */
|
|
@@ -71,13 +88,18 @@ export function ContextInjectionRow({ content, source, provenance, producer, for
|
|
|
71
88
|
</>
|
|
72
89
|
)}
|
|
73
90
|
keepContentWhenOpen
|
|
74
|
-
open={open}
|
|
75
|
-
expandable
|
|
91
|
+
open={open && tools?.single == null}
|
|
92
|
+
expandable={tools?.single == null}
|
|
76
93
|
expandOnRowClick
|
|
77
94
|
onToggle={() => { setOpen(value => !value) }}
|
|
78
95
|
>
|
|
79
96
|
<div className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}>
|
|
80
|
-
{body
|
|
97
|
+
{tools === null ? body : (
|
|
98
|
+
<div className={css.toolChanges}>
|
|
99
|
+
{tools.added.length > 0 && <div>{t('message.toolsAdded', { names: tools.added.join(', ') })}</div>}
|
|
100
|
+
{tools.removed.length > 0 && <div>{t('message.toolsRemoved', { names: tools.removed.join(', ') })}</div>}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
81
103
|
</div>
|
|
82
104
|
</DisclosureRow>
|
|
83
105
|
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** One developer tool-change block the 0.1.7-rc.2 context row names in place. */
|
|
2
|
+
export interface ToolChangeBlock {
|
|
3
|
+
type: 'tool-addition' | 'tool-removal'
|
|
4
|
+
toolName: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Names collected from a context whose every block is a tool change. */
|
|
8
|
+
export interface ToolUpdateRows {
|
|
9
|
+
added: readonly string[]
|
|
10
|
+
removed: readonly string[]
|
|
11
|
+
/** The only block, when the row is a single add or removal. */
|
|
12
|
+
single: ToolChangeBlock | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Classify a context as the rc.2 tool-update row.
|
|
17
|
+
* Mixed or empty content stays on the generic injection presentation.
|
|
18
|
+
* @param content - durable context blocks.
|
|
19
|
+
* @returns collected names, or null when any block is not a tool change.
|
|
20
|
+
*/
|
|
21
|
+
export function toolUpdateRows(content: readonly { readonly type: string; readonly toolName?: string }[]): ToolUpdateRows | null {
|
|
22
|
+
if (content.length === 0) return null
|
|
23
|
+
const rows: ToolChangeBlock[] = []
|
|
24
|
+
for (const block of content) {
|
|
25
|
+
if (block.type !== 'tool-addition' && block.type !== 'tool-removal') return null
|
|
26
|
+
if (typeof block.toolName !== 'string') return null
|
|
27
|
+
rows.push({ type: block.type, toolName: block.toolName })
|
|
28
|
+
}
|
|
29
|
+
const added = rows.flatMap(row => row.type === 'tool-addition' ? [row.toolName] : [])
|
|
30
|
+
const removed = rows.flatMap(row => row.type === 'tool-removal' ? [row.toolName] : [])
|
|
31
|
+
return { added, removed, single: rows.length === 1 ? rows[0]! : null }
|
|
32
|
+
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Web or Desktop file route. Authorization stays Host-side. */
|
|
2
2
|
export function localPathMediaUrl(protocol: string, origin: string, value: string): string | undefined {
|
|
3
|
-
if (protocol !== 'http:' && protocol !== 'https:') return undefined;
|
|
4
3
|
if (!value.startsWith('/') || value.startsWith('//')) return undefined;
|
|
5
|
-
|
|
4
|
+
if (protocol === 'http:' || protocol === 'https:') {
|
|
5
|
+
return `${origin}/api/file?path=${encodeURIComponent(value)}`;
|
|
6
|
+
}
|
|
7
|
+
// Official fileMediaUrl accepts the Desktop application base dsh-app://app/.
|
|
8
|
+
if (protocol === 'dsh-app:' && origin === 'dsh-app://app') {
|
|
9
|
+
return `dsh-app://app/api/file?path=${encodeURIComponent(value)}`;
|
|
10
|
+
}
|
|
11
|
+
return undefined;
|
|
6
12
|
}
|
|
7
13
|
|
|
8
14
|
export const readerPathImages = {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { toolUpdateRows } from '../src/client/native/tool-update.js';
|
|
4
|
+
|
|
5
|
+
test('a single tool addition is its own closed row', () => {
|
|
6
|
+
assert.deepEqual(toolUpdateRows([{ type: 'tool-addition', toolName: 'search' }]), {
|
|
7
|
+
added: ['search'],
|
|
8
|
+
removed: [],
|
|
9
|
+
single: { type: 'tool-addition', toolName: 'search' },
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('mixed tool changes stay one expandable update', () => {
|
|
14
|
+
assert.deepEqual(toolUpdateRows([
|
|
15
|
+
{ type: 'tool-addition', toolName: 'search' },
|
|
16
|
+
{ type: 'tool-addition', toolName: 'read_file' },
|
|
17
|
+
{ type: 'tool-removal', toolName: 'old_search' },
|
|
18
|
+
]), {
|
|
19
|
+
added: ['search', 'read_file'],
|
|
20
|
+
removed: ['old_search'],
|
|
21
|
+
single: null,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('text or a missing tool name stays a generic context row', () => {
|
|
26
|
+
assert.equal(toolUpdateRows([{ type: 'text', toolName: 'search' }]), null);
|
|
27
|
+
assert.equal(toolUpdateRows([{ type: 'tool-addition' }]), null);
|
|
28
|
+
assert.equal(toolUpdateRows([]), null);
|
|
29
|
+
assert.equal(toolUpdateRows([
|
|
30
|
+
{ type: 'tool-addition', toolName: 'search' },
|
|
31
|
+
{ type: 'text' },
|
|
32
|
+
]), null);
|
|
33
|
+
});
|
|
@@ -14,4 +14,10 @@ test('relative, protocol-relative and non-HTTP page paths are not sent to the Ho
|
|
|
14
14
|
assert.equal(localPathMediaUrl('http:', 'http://local', path), undefined);
|
|
15
15
|
}
|
|
16
16
|
assert.equal(localPathMediaUrl('file:', 'null', '/tmp/image.png'), undefined);
|
|
17
|
+
assert.equal(localPathMediaUrl('dsh-app:', 'dsh-app://other', '/tmp/image.png'), undefined);
|
|
18
|
+
});
|
|
19
|
+
test('Desktop file routes use the application file API', () => {
|
|
20
|
+
const url = localPathMediaUrl('dsh-app:', 'dsh-app://app', '/tmp/a.png')!;
|
|
21
|
+
assert.equal(url.startsWith('dsh-app://app/api/file?'), true);
|
|
22
|
+
assert.equal(new URL(url).searchParams.get('path'), '/tmp/a.png');
|
|
17
23
|
});
|
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import { test } from 'node:test';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import semver from 'semver';
|
|
6
7
|
|
|
7
8
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
9
|
const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as {
|
|
@@ -11,8 +12,24 @@ const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as {
|
|
|
11
12
|
exports: Record<string, { default?: string } | string>;
|
|
12
13
|
files: string[];
|
|
13
14
|
dsh: { bundle?: { patch?: string } };
|
|
15
|
+
peerDependencies: Record<string, string>;
|
|
14
16
|
};
|
|
15
17
|
|
|
18
|
+
const HARNESS_PEER = '>=0.2.0-rc.1 <0.2.1';
|
|
19
|
+
|
|
20
|
+
test('Harness 0.2.0 peer range accepts the rc.1 and stable releases', () => {
|
|
21
|
+
const peers = Object.entries(pkg.peerDependencies).filter(([name]) => name.startsWith('@deepseek-ai/dsh-'));
|
|
22
|
+
assert.ok(peers.length >= 12);
|
|
23
|
+
for (const [name, range] of peers) {
|
|
24
|
+
assert.equal(range, HARNESS_PEER, name);
|
|
25
|
+
assert.equal(semver.satisfies('0.2.0-rc.2', range), true, name);
|
|
26
|
+
assert.equal(semver.satisfies('0.2.0', range), true, name);
|
|
27
|
+
assert.equal(semver.satisfies('0.2.0-alpha.1', range), false, name);
|
|
28
|
+
assert.equal(semver.satisfies('0.2.0-alpha', range), false, name);
|
|
29
|
+
assert.equal(semver.satisfies('0.1.7-rc.2', range), false, name);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
16
33
|
test('declares dsh.bundle.patch so official add joins the profile layer stack', () => {
|
|
17
34
|
assert.equal(pkg.dsh.bundle?.patch, './cordis.patch.yml');
|
|
18
35
|
assert.equal(existsSync(resolve(root, 'cordis.patch.yml')), true);
|
package/tsdown.config.ts
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
5
5
|
import type { TsdownPlugin, UserConfig } from 'tsdown';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
const vendored = fileURLToPath(new URL('./tools/client-build.js', import.meta.url));
|
|
8
|
+
|
|
9
|
+
function resolveHarnessAdapter(): string {
|
|
8
10
|
const configured = process.env.DSHX_HARNESS?.trim();
|
|
9
|
-
if (configured) return resolve(configured);
|
|
11
|
+
if (configured) return join(resolve(configured), 'tools/dshx/src/client-build.js');
|
|
10
12
|
const configPath = join(homedir(), '.config/dshx/harness');
|
|
11
13
|
const recorded = existsSync(configPath) ? readFileSync(configPath, 'utf8').trim() : undefined;
|
|
12
14
|
if (!recorded) {
|
|
13
15
|
throw new Error('client rebuild requires a Harness root from DSHX_HARNESS or ~/.config/dshx/harness');
|
|
14
16
|
}
|
|
15
|
-
return resolve(recorded);
|
|
17
|
+
return join(resolve(recorded), 'tools/dshx/src/client-build.js');
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
const adapter =
|
|
20
|
+
const adapter = existsSync(vendored) ? vendored : resolveHarnessAdapter();
|
|
19
21
|
if (!existsSync(adapter)) throw new Error('externalClientBundle adapter is missing.');
|
|
20
22
|
const { externalClientBundle } = await import(pathToFileURL(adapter).href);
|
|
21
23
|
|