Skip to content
Open
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
37 changes: 29 additions & 8 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2501,17 +2501,36 @@ function heldReplyOf(bot, sessionId, turn) {
return found;
}
var unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : []).filter((reply) => reply.sessionId === sessionId && reply.status !== "approved" && reply.status !== "auto").map((reply) => reply.turn);
function previewOf(list, id, hiddenTurns = [], unsentTurns = []) {
function previewOf(list, id, hiddenTurns = [], unsentTurns = [], names = []) {
const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline;
if (!Array.isArray(outline)) return "";
for (let index = outline.length - 1; index >= 0; index -= 1) {
const entry = outline[index];
if (hiddenTurns.includes(entry.turn)) continue;
const text = (unsentTurns.includes(entry.turn) ? "" : entry.response) || entry.prompt || "";
if (text !== "") return visibleText(stripTrace(stripHeader(text)).replace(/\s+/g, " ").trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, ""));
if (text === "") continue;
return readableLine(text, names);
}
return "";
}
function readableLine(text, names = []) {
return dropKnownTrace(collapsedLine(text), names);
}
function collapsedLine(text) {
return visibleText(stripTrace(stripHeader(`${text}`)).replace(/\s+/g, " ").trim());
}
function dropKnownTrace(line2, names) {
const known = [...new Set((Array.isArray(names) ? names : []).map((name) => String(name ?? "").trim()).filter((name) => name !== ""))].sort((a, b) => b.length - a.length);
if (known.length === 0 || line2 === "") return line2;
const hop = known.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
const whole = `(?:${hop})(?=[ \\t→,,.。::]|$)`;
const trace = new RegExp(`^${whole}(?:[ \\t]*→[ \\t]*${whole})+[ \\t]*(?:[,,.。::][ \\t]*)?(?:\\d{1,2}:\\d{2}Z?[ \\t]*)?`);
const match = trace.exec(line2);
if (!match) return line2;
const rest = line2.slice(match[0].length);
return /^[\s,,.。::]*$/.test(rest) ? "" : rest;
}
var botNames = (roster) => [...roster?.bots ?? [], ...roster?.remoteBots ?? [], ...roster?.agents ?? []].map((entry) => entry?.name).filter((name) => typeof name === "string" && name !== "");
var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count);
function usageOf(list, ids) {
let input = 0;
Expand Down Expand Up @@ -7646,7 +7665,7 @@ function latestActivity(card, { roster, work, sessions }) {
const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : void 0) : void 0;
if (entry) options.push(`${workPhrase(entry.tool)}…`);
}
if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat)));
if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat), [], [], botNames(roster)));
return options.find((text) => typeof text === "string" && text !== "" && text !== card.title && text !== card.reason) ?? "";
}
function toolCalls(card, { roster, work }) {
Expand Down Expand Up @@ -7758,7 +7777,7 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now()
const bot = ownBot(roster, entry.botId);
if (bot === void 0 || entry.failed === void 0 || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue;
const chat = entry.roomId ?? bot.id;
const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : "";
const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat), [], [], botNames(roster)) : "";
needs.push({
id: `turn:${entry.id}`,
kind: "turn",
Expand Down Expand Up @@ -7801,7 +7820,7 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date.now()
const part = currentPart(bot);
const at = sessions.byId[part]?.updatedAt;
if (!fresh(at)) continue;
const text = previewOf(sessions, part);
const text = previewOf(sessions, part, [], [], botNames(roster));
if (text === "") continue;
done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text, place: "", chat: bot.id, ...people(roster, bot.id) });
}
Expand Down Expand Up @@ -8853,7 +8872,7 @@ var workLine = (lead, tool, more) => (0, import_react16.createElement)(Shimmer,
function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) {
const id = bot.id;
const part = currentPart(bot);
const preview = useSessions((list) => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part)), roster));
const preview = useSessions((list) => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part), botNames(roster)), roster));
const running = useSessionStatus((map) => map.get(part)?.running === true);
const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0);
const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active;
Expand Down Expand Up @@ -8988,7 +9007,7 @@ function FaceMark() {
}
function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) {
const id = room2.id;
const fallback = useSessions((list) => nameMentions(previewOf(list, id), roster));
const fallback = useSessions((list) => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster));
const preview = nameMentions(visibleText(room2.preview), roster) || fallback;
const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0);
const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active;
Expand Down Expand Up @@ -13305,6 +13324,7 @@ button.bt-face-bot:hover{color:var(--bt-ink);text-decoration:underline;text-unde
// src/client/palette.js
var import_react29 = require("react");
var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives");
var paletteText = (raw, names) => readableLine(String(raw ?? ""), names);
var PALETTE_EXIT_MS = 160;
function CommandPalette({ start, token, roster, actions, useSessions }) {
const [query, setQuery] = (0, import_react29.useState)(start ?? "");
Expand All @@ -13318,6 +13338,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) {
};
useEscape(close);
const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)];
const names = botNames(roster);
const found = useSessions((list) => {
if (wanted.length < 2) return "[]";
const hits = [];
Expand All @@ -13329,7 +13350,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) {
const entry = outline[index2];
if (hidden.includes(entry.turn)) continue;
for (const raw of [entry.response, entry.prompt]) {
const text = stripHeader(raw ?? "").replace(/\s+/g, " ").trim();
const text = paletteText(raw, names);
const at = text.toLowerCase().indexOf(wanted);
if (at < 0) continue;
const from = Math.max(0, at - 24);
Expand Down
8 changes: 4 additions & 4 deletions core/src/client/inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { workPhrase } from './cells.js'
import { elapsedLabel } from './activity-row.js'
import { useEscape } from './overlay-hooks.js'
import { alwaysChoices } from './member-status.js'
import { currentPart, previewOf, relativeShort } from './text.js'
import { botNames, currentPart, previewOf, relativeShort } from './text.js'
import { hostText, t } from './i18n.js'
import { failureSentence } from '../shared/failure-copy.js'

Expand Down Expand Up @@ -129,7 +129,7 @@ function latestActivity(card, { roster, work, sessions }) {
const entry = card.executingBot ? runningIn(work, card.executingBot, roster.roomsById?.[card.chat] ? card.chat : undefined) : undefined
if (entry) options.push(`${workPhrase(entry.tool)}…`)
}
if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat)))
if (sessions?.byId && card.chat) options.push(previewOf(sessions, partOf(roster, card.chat), [], [], botNames(roster)))
return options.find(text => typeof text === 'string' && text !== '' && text !== card.title && text !== card.reason) ?? ''
}

Expand Down Expand Up @@ -230,7 +230,7 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date
const bot = ownBot(roster, entry.botId)
if (bot === undefined || entry.failed === undefined || !fresh(entry.at) || seen.has(key) || waitingAt.has(key)) continue
const chat = entry.roomId ?? bot.id
const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat)) : ''
const asked = sessions?.byId ? previewOf(sessions, partOf(roster, chat), [], [], botNames(roster)) : ''
needs.push({
id: `turn:${entry.id}`, kind: 'turn', status: 'failed', bot, at: entry.at,
title: asked || t('The last turn did not finish'), reason: entry.failed,
Expand Down Expand Up @@ -261,7 +261,7 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, now = Date
const part = currentPart(bot)
const at = sessions.byId[part]?.updatedAt
if (!fresh(at)) continue
const text = previewOf(sessions, part)
const text = previewOf(sessions, part, [], [], botNames(roster))
if (text === '') continue
done.push({ id: `chat:${bot.id}`, kind: 'chat', status: 'done', bot, at, title: text, place: '', chat: bot.id, ...people(roster, bot.id) })
}
Expand Down
9 changes: 6 additions & 3 deletions core/src/client/palette.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createElement as h, Fragment, useEffect, useState } from 'react'
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
import { isMainOf } from './sources.js'
import { currentPart, stripHeader } from './text.js'
import { botNames, currentPart, readableLine } from './text.js'
import { PlusIcon, SearchIcon, PeopleIcon, CloseIcon, GearIcon } from './icons.js'
import { BotAvatar, RoomAvatar } from './mark.js'
import { useEscape } from './overlay-hooks.js'
Expand All @@ -12,7 +12,9 @@ import { seatedCount } from './group-settings.js'

// Search is a command palette: a 560px dialog, a search header, and 49px
// rows (24px avatar, title, subtitle). Bots and groups match on name and role; past
// messages match on their text.
// messages match on their text, read by `readableLine`, the same reader `previewOf` uses — a header,
// a delegation trace and a fence are routing or data, never the row (#1347).
const paletteText = (raw, names) => readableLine(String(raw ?? ''), names)
const PALETTE_EXIT_MS = 160
export function CommandPalette({ start, token, roster, actions, useSessions }) {
const [query, setQuery] = useState(start ?? '')
Expand All @@ -26,6 +28,7 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) {
}
useEscape(close)
const ids = [...roster.bots.map(currentPart), ...roster.rooms.map(item => item.id)]
const names = botNames(roster)
const found = useSessions((list) => {
if (wanted.length < 2) return '[]'
const hits = []
Expand All @@ -37,7 +40,7 @@ export function CommandPalette({ start, token, roster, actions, useSessions }) {
const entry = outline[index]
if (hidden.includes(entry.turn)) continue
for (const raw of [entry.response, entry.prompt]) {
const text = stripHeader(raw ?? '').replace(/\s+/g, ' ').trim()
const text = paletteText(raw, names)
const at = text.toLowerCase().indexOf(wanted)
if (at < 0) continue
const from = Math.max(0, at - 24)
Expand Down
6 changes: 3 additions & 3 deletions core/src/client/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Component, createElement as h, Fragment, useEffect, useLayoutEffect, us
import { createPortal } from 'react-dom'
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
import { isMainOf } from './sources.js'
import { awayGroups, botRowTitle, currentPart, faceRowTitle, modelOf, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js'
import { awayGroups, botNames, botRowTitle, currentPart, faceRowTitle, modelOf, nameMentions, previewOf, relativeShort, unsentReplyTurns } from './text.js'
import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon, FlyBotMark } from './icons.js'
import { MAIN_LOOK } from './characters.js'
import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js'
Expand Down Expand Up @@ -45,7 +45,7 @@ const workLine = (lead, tool, more) => h(Shimmer, null, `${lead} · ${workPhrase
function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) {
const id = bot.id
const part = currentPart(bot)
const preview = useSessions(list => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part)), roster))
const preview = useSessions(list => nameMentions(previewOf(list, part, roster.exchangeTurns[part], unsentReplyTurns(bot, part), botNames(roster)), roster))
const running = useSessionStatus(map => map.get(part)?.running === true)
const active = useSessionRetainInfo(part, info => (info?.retainedBy?.mainView ?? 0) > 0)
const unread = useSessionStatus(map => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== undefined) || (marked && !active)
Expand Down Expand Up @@ -149,7 +149,7 @@ function FaceMark() {

function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) {
const id = room.id
const fallback = useSessions(list => nameMentions(previewOf(list, id), roster))
const fallback = useSessions(list => nameMentions(previewOf(list, id, [], [], botNames(roster)), roster))
// The host's line is the same kind of stored text as a Bot chat's, so the row reads it the same way:
// a fence or a marker fragment must never reach the row (#1347).
const preview = nameMentions(visibleText(room.preview), roster) || fallback
Expand Down
49 changes: 45 additions & 4 deletions core/src/client/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,21 +276,62 @@ export function heldReplyOf(bot, sessionId, turn) {
export const unsentReplyTurns = (bot, sessionId) => (Array.isArray(bot?.heldReplies) ? bot.heldReplies : [])
.filter(reply => reply.sessionId === sessionId && reply.status !== 'approved' && reply.status !== 'auto').map(reply => reply.turn)

// `previewOf(list, id, hiddenTurns, unsentTurns, names)`: the newest visible line of that Session. An
// empty turn is no line at all, so the walk keeps going to older turns; only a walk that ends still
// empty reads as nothing. The line it settles on is read by `readableLine`, the one reader a search row
// uses too, with the same `names`.
// `unsentTurns`: turns whose reply is held or was not sent; the preview shows their prompt instead.
export function previewOf(list, id, hiddenTurns = [], unsentTurns = []) {
// `names`: the conversation's real Bot names; a delegation trace is only a trace when every hop is one
// of them, so `Settings → Models 页面` stays a line while `Chief → 助手 Hi there` loses its routing.
export function previewOf(list, id, hiddenTurns = [], unsentTurns = [], names = []) {
const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline
if (!Array.isArray(outline)) return ''
for (let index = outline.length - 1; index >= 0; index -= 1) {
const entry = outline[index]
if (hiddenTurns.includes(entry.turn)) continue
const text = (unsentTurns.includes(entry.turn) ? '' : entry.response) || entry.prompt || ''
// The outline flattens a reply to one line, so its trace ends at a space there. A stored line can
// still carry its untrusted fence, and a row is one line someone reads: no marker reaches it (#1347).
if (text !== '') return visibleText(stripTrace(stripHeader(text)).replace(/\s+/g, ' ').trim().replace(/^[^\s→]{1,40}(?: → [^\s→]{1,40})+ (?=\S)/, ''))
if (text === '') continue
return readableLine(text, names)
}
return ''
}

// `readableLine(text, names)`: one stored line a caller already holds — the line `previewOf` settled on,
// or a line the palette renders as a search row — read the one way a person reads it, so a row cannot
// have a second reading of what a preview shows (#1347). `names` is the conversation's real Bot names,
// this one included; with no names nothing that merely looks like a trace is removed (#1408).
export function readableLine(text, names = []) {
return dropKnownTrace(collapsedLine(text), names)
}

// The one sanitized reading of a stored line: no header, no fence, no wrap, one line of words.
function collapsedLine(text) {
return visibleText(stripTrace(stripHeader(`${text}`)).replace(/\s+/g, ' ').trim())
}

// The outline flattens a reply to one line, so a delegation trace arrives as a prefix: `Chief → 助手 Hi`
// is routing plus words, while `Settings → Models 页面` is a line someone wrote. Only a prefix whose
// every hop is a known name — longest name first — is removed, and only when words follow it; a hop may
// carry punctuation and a clock (`Chief → Product,03:16Z 审计。`, #1408).
function dropKnownTrace(line, names) {
const known = [...new Set((Array.isArray(names) ? names : []).map(name => String(name ?? '').trim()).filter(name => name !== ''))].sort((a, b) => b.length - a.length)
if (known.length === 0 || line === '') return line
const hop = known.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
// A hop is a whole name: `Chief → 助手们 Hi` is not `Chief → 助手` plus words.
const whole = `(?:${hop})(?=[ \\t→,,.。::]|$)`
const trace = new RegExp(`^${whole}(?:[ \\t]*→[ \\t]*${whole})+[ \\t]*(?:[,,.。::][ \\t]*)?(?:\\d{1,2}:\\d{2}Z?[ \\t]*)?`)
const match = trace.exec(line)
if (!match) return line
// A trace with nothing after it, or only its own punctuation, is no row at all.
const rest = line.slice(match[0].length)
return /^[\s,,.。::]*$/.test(rest) ? '' : rest
}

// The names a stored delegation trace can be built from: the roster's Bots, remote Bots and agents.
// A chat whose roster is not loaded still reads its text, just never as a trace.
export const botNames = roster => [...(roster?.bots ?? []), ...(roster?.remoteBots ?? []), ...(roster?.agents ?? [])]
.map(entry => entry?.name).filter(name => typeof name === 'string' && name !== '')

const formatTokens = count => (count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count))

// Same buckets as the shell's usage pill: every prompt-side billing bucket plus output.
Expand Down
7 changes: 4 additions & 3 deletions core/test/inbound-ui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ test('ordinary text that merely mentions an arrow is left alone', () => {
})

test('the sidebar preview drops the trace too', () => {
const names = ['Chief', '助手']
const list = { byId: { s: { projectionValues: { turnOutline: [{ turn: 2, prompt: '', response: 'Chief → 助手\n\nHi Chief!' }] } } } }
assert.equal(previewOf(list, 's'), 'Hi Chief!')
assert.equal(previewOf(list, 's', [], [], names), 'Hi Chief!')
const flat = { byId: { s: { projectionValues: { turnOutline: [{ turn: 1, prompt: '', response: 'Chief → 助手 Hi! Message received.' }] } } } }
assert.equal(previewOf(flat, 's'), 'Hi! Message received.')
assert.equal(previewOf(flat, 's', [], [], names), 'Hi! Message received.')
const plain = { byId: { s: { projectionValues: { turnOutline: [{ turn: 1, prompt: '', response: 'Use Settings → Models first' }] } } } }
assert.equal(previewOf(plain, 's'), 'Use Settings → Models first')
assert.equal(previewOf(plain, 's', [], [], names), 'Use Settings → Models first')
})

test("an inbound friend's message uses the friend's sidebar Bot (avatar, name), else a stable look from its handle", () => {
Expand Down
Loading
Loading