Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -4672,6 +4672,7 @@ var CONVERSATION_CSS = `
.bt-think summary::-webkit-details-marker{display:none}
.bt-think-caret{display:inline-block;transition:transform .15s;font-size:14px;line-height:1}
.bt-think[open] .bt-think-caret{transform:rotate(90deg)}
.bt-think-peek{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-3)}
.bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))}
.bt-person-initial{color:#fff;font-weight:600;line-height:1;user-select:none}
.bt-person-msg .bt-group-name{color:var(--bt-ink-2,rgba(20,20,20,.7))}
Expand Down Expand Up @@ -6167,12 +6168,24 @@ function MessageTools({ text, name, sessionId, react, actions }) {
), document.body) : null
);
}
var PEEK_MAX = 160;
var peekLine = (text) => {
const line2 = text.split("\n").map((part) => part.replace(/\s+/g, " ").trim()).find((part) => part !== "");
return line2 === void 0 ? "" : line2.slice(0, PEEK_MAX);
};
function ThinkingRow({ text }) {
if (typeof text !== "string" || text.trim() === "") return null;
const peek = peekLine(text);
return (0, import_react9.createElement)(
"details",
{ className: "bt-think" },
(0, import_react9.createElement)("summary", null, (0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), t("Thought it through")),
(0, import_react9.createElement)(
"summary",
null,
(0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"),
t("Thought it through"),
peek ? (0, import_react9.createElement)("span", { className: "bt-think-peek" }, peek) : null
),
(0, import_react9.createElement)("div", { className: "bt-think-text" }, text)
);
}
Expand Down
12 changes: 11 additions & 1 deletion core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,21 @@ function MessageTools({ text, name, sessionId, react, actions }) {
item(t('Copy'), CopyIcon, copy), item(t('Download'), DownloadIcon, download)), document.body) : null)
}

// The first line with something on it, inner runs of whitespace squeezed: a folded row shows it, so
// the row never reads as an empty frame while the text it names sits behind the caret (#1624).
const PEEK_MAX = 160
const peekLine = (text) => {
const line = text.split('\n').map((part) => part.replace(/\s+/g, ' ').trim()).find((part) => part !== '')
return line === undefined ? '' : line.slice(0, PEEK_MAX)
}

function ThinkingRow({ text }) {
// Folded by default; the caret shows there is something inside. Nothing to show, no row.
if (typeof text !== 'string' || text.trim() === '') return null
const peek = peekLine(text)
return h('details', { className: 'bt-think' },
h('summary', null, h('span', { className: 'bt-think-caret', 'aria-hidden': true }, '›'), t('Thought it through')),
h('summary', null, h('span', { className: 'bt-think-caret', 'aria-hidden': true }, '›'), t('Thought it through'),
peek ? h('span', { className: 'bt-think-peek' }, peek) : null),
h('div', { className: 'bt-think-text' }, text))
}

Expand Down
1 change: 1 addition & 0 deletions core/src/client/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,7 @@ export const CONVERSATION_CSS = `
.bt-think summary::-webkit-details-marker{display:none}
.bt-think-caret{display:inline-block;transition:transform .15s;font-size:14px;line-height:1}
.bt-think[open] .bt-think-caret{transform:rotate(90deg)}
.bt-think-peek{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-3)}
.bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))}
.bt-person-initial{color:#fff;font-weight:600;line-height:1;user-select:none}
.bt-person-msg .bt-group-name{color:var(--bt-ink-2,rgba(20,20,20,.7))}
Expand Down
122 changes: 122 additions & 0 deletions core/test/thinking-row.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { build } from 'esbuild'

// The thinking row, rendered through AssistantCell: bundled once and rendered with a minimal
// React whose components run as plain functions, as in tool-cells.test.js.
const { outputFiles } = await build({
stdin: {
contents: "export { AssistantCell } from './src/client/cells.js'",
resolveDir: fileURLToPath(new URL('..', import.meta.url)),
},
bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent',
external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'],
})
let effects = []
const Fragment = Symbol('Fragment')
const h = (type, props, ...children) => ({ type, props: { ...(props ?? {}), children } })
const react = {
createElement: h, Fragment,
useEffect: effect => { effects.push(effect) },
useLayoutEffect: effect => { effects.push(effect) },
useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}],
useMemo: make => make(), useRef: current => ({ current }), useId: () => 'cell-id',
}
const module = { exports: {} }
runInNewContext(outputFiles[0].text, {
module, exports: module.exports,
document: { hidden: true },
setTimeout: () => ({ fn: () => {} }), clearTimeout: () => {},
require: (name) => {
if (name === 'react') return react
if (name === 'react-dom') return { createPortal: node => node }
if (name === '@deepseek-ai/dsh-client-ui-primitives') return { MarkdownText: ({ text }) => text }
throw new Error(`Unexpected platform module ${name}`)
},
})
const { AssistantCell } = module.exports

const expand = (node) => {
if (node === null || node === undefined || typeof node === 'boolean') return null
if (Array.isArray(node)) return node.map(expand)
if (typeof node !== 'object') return node
if (typeof node.type === 'function') return expand(node.type(node.props))
return { ...node, props: { ...node.props, children: expand(node.props.children) } }
}
const text = (node) => {
if (node === null || node === undefined) return ''
if (Array.isArray(node)) return node.map(text).join('')
return typeof node === 'object' ? text(node.props.children) : String(node)
}
const find = (node, match) => {
if (Array.isArray(node)) return node.map(child => find(child, match)).find(Boolean)
if (!node || typeof node !== 'object') return undefined
return match(node) ? node : find(node.props.children, match)
}

const bot = { id: 'bot', name: 'Bot', color: 'blue' }
const roster = { bots: [bot], byId: { bot }, roomsById: {}, exchangeTurns: {} }
const step = (n, blocks, status = 'settled') => ({ turn: 7, step: n, status, blocks })
const think = (text) => ({ kind: 'reasoning', text })

// One assistant-step seat of a turn, as DSH hands it to the cell.
const render = (data, steps, groupPart = 'reasoning') => expand(AssistantCell({
node: { key: `${data.turn}:${data.step}`, data },
groupPart,
sessionId: 'bot',
openFile: () => {}, fileMentions: () => {},
useTurnData: () => undefined,
useRoster: pick => pick(roster),
useUi: pick => pick({}),
useTurnSteps: () => steps,
useTurnContext: () => undefined,
actions: { noteLive: () => {}, openExchange: () => {}, quote: () => {} },
}))
const thinkingRow = (tree) => find(tree, node => node.props?.className === 'bt-think')
const classed = (node, className) => find(node, child => child.props?.className === className)

test('#1624: a settled step shows a line of its reasoning in the closed row, not a bare 思考过程 strip', () => {
const blocks = [think('Looking at the store first\nThen the second line'), { kind: 'text', text: 'Here is the answer' }]
const steps = [step(1, blocks)]
const tree = render(step(1, blocks), steps)
const details = thinkingRow(tree)
assert.ok(details, 'the settled reasoning has its row')
// Folded by default: the caret still says there is more inside.
assert.equal(details.props.open, undefined)
const summary = find(details, node => node.type === 'summary')
assert.ok(summary, 'the row has its summary')
// The visible strip carries a line of what it names, so it never reads as an empty frame.
const peek = classed(summary, 'bt-think-peek')
assert.ok(peek, 'the closed row carries a line of the reasoning')
assert.equal(text(peek), 'Looking at the store first')
assert.doesNotMatch(text(peek), /Then the second line/)
})

test('#1624: the peek line is capped, so one long line cannot fill the row', () => {
const blocks = [think('x'.repeat(400))]
const peek = classed(thinkingRow(render(step(1, blocks), [step(1, blocks)])), 'bt-think-peek')
assert.ok(peek, 'the closed row carries its line')
assert.equal(text(peek).length, 160)
})

test('#1624: the row still holds the whole reasoning once opened', () => {
const blocks = [think('Looking at the store first\nThen the second line')]
const details = thinkingRow(render(step(1, blocks), [step(1, blocks)]))
const body = classed(details, 'bt-think-text')
assert.ok(body, 'the row has a body')
assert.equal(text(body), 'Looking at the store first\nThen the second line')
})

test('#1624: a step that is still thinking draws no row at all', () => {
const blocks = [think('Half a thought')]
const live = step(1, blocks, 'running')
const tree = render(live, [live])
assert.equal(thinkingRow(tree), undefined)
})

test('#1624: reasoning that is only whitespace draws no row at all', () => {
const blocks = [think(' \n\t '), { kind: 'text', text: 'Answer' }]
assert.equal(thinkingRow(render(step(1, blocks), [step(1, blocks)])), undefined)
})
18 changes: 15 additions & 3 deletions patches/ds-bot/0003-flybot-api-client.patch
Original file line number Diff line number Diff line change
Expand Up @@ -529,15 +529,26 @@ diff --git a/src/client/cells.js b/src/client/cells.js
// Closes a popover on a press outside the given elements or on Escape.
function useDismiss(open, close, refs) {
useEffect(() => {
@@ -101,8 +101,10 @@ function MessageTools({ text, name, sessionId, react, actions }) {
@@ -100,9 +100,21 @@ function MessageTools({ text, name, sessionId, react, actions }) {
item(t('Copy'), CopyIcon, copy), item(t('Download'), DownloadIcon, download)), document.body) : null)
}

+// The first line with something on it, inner runs of whitespace squeezed: a folded row shows it, so
+// the row never reads as an empty frame while the text it names sits behind the caret (#1624).
+const PEEK_MAX = 160
+const peekLine = (text) => {
+ const line = text.split('\n').map((part) => part.replace(/\s+/g, ' ').trim()).find((part) => part !== '')
+ return line === undefined ? '' : line.slice(0, PEEK_MAX)
+}
+
function ThinkingRow({ text }) {
+ // Folded by default; the caret shows there is something inside. Nothing to show, no row.
+ if (typeof text !== 'string' || text.trim() === '') return null
+ const peek = peekLine(text)
return h('details', { className: 'bt-think' },
- h('summary', null, t('Thought it through')),
+ h('summary', null, h('span', { className: 'bt-think-caret', 'aria-hidden': true }, '›'), t('Thought it through')),
+ h('summary', null, h('span', { className: 'bt-think-caret', 'aria-hidden': true }, '›'), t('Thought it through'),
+ peek ? h('span', { className: 'bt-think-peek' }, peek) : null),
h('div', { className: 'bt-think-text' }, text))
}

Expand Down Expand Up @@ -2993,12 +3004,13 @@ diff --git a/src/client/styles.js b/src/client/styles.js
.bt-mem-scrim{animation:bt-fade .2s ease-out}
.bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
.bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box}
@@ -782,6 +782,14 @@ export const CONVERSATION_CSS = `
@@ -782,6 +782,15 @@ export const CONVERSATION_CSS = `
.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)}
.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px}
.bt-think summary::-webkit-details-marker{display:none}
+.bt-think-caret{display:inline-block;transition:transform .15s;font-size:14px;line-height:1}
+.bt-think[open] .bt-think-caret{transform:rotate(90deg)}
+.bt-think-peek{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-3)}
+.bt-person-mark{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-ink-5,rgba(20,20,20,.08));color:var(--bt-ink-3,rgba(20,20,20,.45))}
+.bt-person-initial{color:#fff;font-weight:600;line-height:1;user-select:none}
+.bt-person-msg .bt-group-name{color:var(--bt-ink-2,rgba(20,20,20,.7))}
Expand Down
Loading
Loading