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.
Files changed (45) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.en.md +13 -15
  3. package/README.md +13 -15
  4. package/compat/harness-rc2.json +11 -9
  5. package/docs/official-rendering-bridge.md +1 -1
  6. package/lib/client.js +829 -2680
  7. package/lib/client.js.map +1 -1
  8. package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
  9. package/lib/types/client/ChoreographedFlow.js +6 -0
  10. package/lib/types/client/ChoreographedFlow.js.map +1 -1
  11. package/lib/types/client/Reader.js +1 -1
  12. package/lib/types/client/index.d.ts.map +1 -1
  13. package/lib/types/client/index.js.map +1 -1
  14. package/lib/types/client/markdown/render.d.ts.map +1 -1
  15. package/lib/types/client/markdown/render.js +2 -1
  16. package/lib/types/client/markdown/render.js.map +1 -1
  17. package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -1
  18. package/lib/types/client/native/ContextInjectionRow.js +15 -3
  19. package/lib/types/client/native/ContextInjectionRow.js.map +1 -1
  20. package/lib/types/client/native/tool-update.d.ts +23 -0
  21. package/lib/types/client/native/tool-update.d.ts.map +1 -0
  22. package/lib/types/client/native/tool-update.js +22 -0
  23. package/lib/types/client/native/tool-update.js.map +1 -0
  24. package/lib/types/client/platform-media.d.ts +1 -1
  25. package/lib/types/client/platform-media.d.ts.map +1 -1
  26. package/lib/types/client/platform-media.js +9 -4
  27. package/lib/types/client/platform-media.js.map +1 -1
  28. package/package.json +22 -17
  29. package/scripts/build-motion-fixture.mjs +12 -0
  30. package/scripts/link-harness-dependencies.mjs +2 -0
  31. package/scripts/verify-flowcell.mjs +87 -0
  32. package/scripts/verify-tool-shimmer.mjs +62 -0
  33. package/src/client/ChoreographedFlow.tsx +5 -0
  34. package/src/client/Reader.module.css +5 -2
  35. package/src/client/Reader.tsx +1 -1
  36. package/src/client/index.tsx +2 -0
  37. package/src/client/markdown/render.tsx +2 -1
  38. package/src/client/native/ContextInjectionRow.module.css +4 -0
  39. package/src/client/native/ContextInjectionRow.tsx +28 -6
  40. package/src/client/native/tool-update.ts +32 -0
  41. package/src/client/platform-media.ts +9 -3
  42. package/tests/context-tool-update.test.ts +33 -0
  43. package/tests/platform-media.test.ts +6 -0
  44. package/tests/stock-install.test.ts +17 -0
  45. 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
- background-size: 220% 100%;
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: -120% 0; } }
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;
@@ -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.1" data-dsh-better-display="0.3.1" 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'}>
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}>
@@ -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
- if (['http:', 'https:', 'blob:', 'data:'].includes(new URL(rewritten).protocol)) imageSrc = rewritten
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) {
@@ -60,3 +60,7 @@
60
60
  /* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */
61
61
  font: 400 11px/16px var(--ds-font-family-code);
62
62
  }
63
+
64
+ .toolChanges {
65
+ white-space: nowrap;
66
+ }
@@ -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={t(view.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
58
- collapsedContent={view.label === null ? undefined : (
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
- /** RC2 Chat's same-origin local media contract. Authorization stays Host-side. */
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
- return `${origin}/api/file?path=${encodeURIComponent(value)}`;
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
- function resolveHarness(): string {
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 = join(resolveHarness(), 'tools/dshx/src/client-build.js');
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