feat(web): make produced-file overflow discoverable

This commit is contained in:
ZiyaZhang
2026-08-10 08:08:24 -07:00
parent a40155ad23
commit ee1a88c9f1
40 changed files with 748 additions and 122 deletions

View File

@@ -1,24 +1,39 @@
/* Turn-tail produced-files row: a quiet label followed by wrapping file chips.
Sits between the assistant body and its IconActions footer, so it reads as
part of the answer rather than as another tool row. */
/* Turn-tail produced-files summary: one measured chip lane plus an optional
native-folder action below it. */
.root {
display: flex;
flex-wrap: wrap;
position: relative;
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
gap: 8px;
column-gap: 8px;
row-gap: 6px;
margin-top: 16px;
font-size: 13px;
line-height: 22px;
}
.label {
grid-column: 1;
grid-row: 1;
color: var(--dsw-alias-label-tertiary);
}
.row {
grid-column: 2;
grid-row: 1;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
}
/* One produced file. A link by behavior (it opens the file), a chip by shape:
full paths are long and several may wrap onto one row. */
full paths are long, while the measured lane stays on one row. */
.file {
flex: 0 0 auto;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
@@ -38,7 +53,53 @@
text-decoration: underline;
}
.file:focus-visible,
.showFolder:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3);
}
/* Overflow count: the row never silently drops files it did not show. */
.more {
flex: 0 0 auto;
white-space: nowrap;
color: var(--dsw-alias-label-tertiary);
}
.showFolder {
grid-column: 2;
grid-row: 2;
justify-self: start;
margin: 0;
padding: 0 2px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: inherit;
line-height: 20px;
cursor: pointer;
}
.showFolder:hover {
color: var(--dsw-alias-label-secondary);
text-decoration: underline;
}
/* Exact browser-native probes for every candidate shown count. They share the
visible styles but never affect layout, accessibility, or scroll width. */
.measure {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
contain: strict;
}
.probe {
position: absolute;
inset: 0 auto auto 0;
width: max-content;
}

View File

@@ -4,46 +4,146 @@
// through the same openFile the tool rows use — the Host's own opener, on the
// Host machine.
import { useLayoutEffect, useRef, useState } from 'react'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { basename } from './turn-deliverables.ts'
import type { NS } from './locales.ts'
import css from './ProducedFiles.module.css'
/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */
const SHOWN = 6
/** At most six chips compete for the one-line summary; every other path stays counted. */
const SHOWN_LIMIT = 6
/**
* Select the largest prefix whose measured chips and exact remainder fit.
* @param available - usable width of the one-line file lane.
* @param gap - computed flex gap between adjacent visible items.
* @param chipWidths - measured widths for the candidate file chips.
* @param moreWidthsByShown - exact localized remainder width for each shown count.
* @returns Number of leading chips to render.
*/
export function fitProducedFiles(
available: number,
gap: number,
chipWidths: readonly number[],
moreWidthsByShown: readonly (number | undefined)[],
): number {
if (available <= 0) return chipWidths.length
const prefix = [0]
for (const width of chipWidths) prefix.push((prefix.at(-1) ?? 0) + width)
for (let shown = chipWidths.length; shown >= 0; shown -= 1) {
const more = moreWidthsByShown[shown]
const items = shown + (more === undefined ? 0 : 1)
const needed = (prefix[shown] ?? 0) + (more ?? 0) + Math.max(0, items - 1) * gap
if (needed <= available) return shown
}
return 0
}
/** Matched paths plus the opener and locale seats needed to present them. */
export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & {
matched: readonly string[]
/** True only when this loopback deployment exposes a user-visible native opener. */
canOpenPath: boolean
} & PropsLocale<typeof NS>
/** Slot-owned props before the connection capability is injected. */
export type ProducedFilesSeatProps = Omit<ProducedFilesProps, 'canOpenPath'>
function moreLabel(t: ProducedFilesProps['t'], count: number): string {
return count === 1 ? t('produced.moreOne') : t('produced.more', { count: String(count) })
}
/**
* Render one turn's produced files as openable chips.
* @param props - selector-matched paths, the chat view's file opener, and the locale seat.
* @returns The produced-files row.
*/
export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) {
const shown = paths.slice(0, SHOWN)
export function ProducedFiles({ matched: paths, openFile, canOpenPath, t }: ProducedFilesProps) {
const limit = Math.min(paths.length, SHOWN_LIMIT)
const [shownCount, setShownCount] = useState(limit)
const rowRef = useRef<HTMLDivElement>(null)
const chipProbes = useRef<Array<HTMLButtonElement | null>>([])
const moreProbes = useRef<Array<HTMLSpanElement | null>>([])
useLayoutEffect(() => {
const row = rowRef.current
if (row === null) return
const measure = (): void => {
const styles = getComputedStyle(row)
const gap = Number.parseFloat(styles.columnGap || styles.gap) || 0
const chips = chipProbes.current.slice(0, limit)
.map(probe => probe?.getBoundingClientRect().width ?? 0)
const more = Array.from({ length: limit + 1 }, (_, candidate) =>
paths.length === candidate
? undefined
: moreProbes.current[candidate]?.getBoundingClientRect().width)
setShownCount(fitProducedFiles(row.clientWidth, gap, chips, more))
}
measure()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(row)
for (const probe of [...chipProbes.current, ...moreProbes.current]) {
if (probe !== null) observer.observe(probe)
}
return () => { observer.disconnect() }
}, [limit, paths, t])
const visibleCount = Math.min(shownCount, limit)
const shown = paths.slice(0, visibleCount)
const hidden = paths.length - shown.length
return (
<div className={css.root}>
<span className={css.label}>{t('produced.label')}</span>
{shown.map(path => (
<button
key={path}
type="button"
className={css.file}
// The full path is the disambiguator when two turns produce files
// that share a basename; the chip itself stays short.
title={path}
aria-label={t('produced.open', { name: path })}
onClick={() => { openFile(path) }}
>
{basename(path)}
<div ref={rowRef} className={css.row} data-produced-files-row>
{shown.map(path => (
<button
key={path}
type="button"
className={css.file}
// The full path is the disambiguator when two turns produce files
// that share a basename; the chip itself stays short.
title={path}
aria-label={t('produced.open', { name: path })}
onClick={() => { openFile(path) }}
>
{basename(path)}
</button>
))}
{hidden > 0 && <span className={css.more}>{moreLabel(t, hidden)}</span>}
</div>
{hidden > 0 && canOpenPath && (
<button type="button" className={css.showFolder} onClick={() => { openFile('.') }}>
{t('produced.showInFolder')}
</button>
))}
{hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>}
)}
<div className={css.measure} aria-hidden="true">
{paths.slice(0, limit).map((path, index) => (
<button
key={path}
ref={(node) => { chipProbes.current[index] = node }}
type="button"
tabIndex={-1}
className={`${css.file} ${css.probe}`}
>
{basename(path)}
</button>
))}
{Array.from({ length: limit + 1 }, (_, candidate) => {
const remaining = paths.length - candidate
if (remaining === 0) return null
return (
<span
key={candidate}
ref={(node) => { moreProbes.current[candidate] = node }}
className={`${css.more} ${css.probe}`}
>
{moreLabel(t, remaining)}
</span>
)
})}
</div>
</div>
)
}

View File

@@ -7,10 +7,14 @@
* composing this plugin out of cordis.yml removes both surfaces entirely;
* the owning view renders an empty chain and inert prose at zero cost.
*/
import { createElement, useSyncExternalStore } from 'react'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import { ProducedFiles } from './ProducedFiles.tsx'
import {
ProducedFiles, type ProducedFilesSeatProps,
} from './ProducedFiles.tsx'
import { en, NS, zh, type DeliverablesKey } from './locales.ts'
import {
deliverablesDefinition, producedFileMentions, selectProducedFiles,
@@ -27,13 +31,24 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx'
export { producedForClosing } from './turn-deliverables.ts'
/** Required services for the tail-slot registration and its dictionaries. */
export const inject = ['slots', 'locale', 'conversationEvents']
export const inject = ['slots', 'locale', 'conversationEvents', 'connection']
/**
* Client plugin body: register the dictionaries and the turn-tail entry.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const connection = ctx.get('connection') as ConnectionHandle
const ProducedFilesSeat = (props: ProducedFilesSeatProps): ReturnType<typeof createElement> => {
const description = useSyncExternalStore(
listener => connection.hostDescription.subscribe(listener),
() => connection.hostDescription.getSnapshot(),
)
return createElement(ProducedFiles, {
...props,
canOpenPath: connection.isLoopback && description?.canOpenPath === true,
})
}
ctx.conversationEvents.register(deliverablesDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
ctx.slots.inject(
@@ -42,7 +57,7 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.chat.turnTail',
select: selectProducedFiles,
locale: NS,
}, ProducedFiles),
}, ProducedFilesSeat),
)
// The prose side of the same vocabulary: the chat view reaches this face
// via ctx.get, so its absence — this plugin composed out — is the off state.

View File

@@ -6,15 +6,19 @@ export const NS = 'deliverables'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'produced.label': '产物',
'produced.more': '还有 {count} 个',
'produced.moreOne': '+ 1 个文件',
'produced.more': '+ {count} 个文件',
'produced.open': '打开 {name}',
'produced.showInFolder': '在文件夹中显示',
}
/** English dictionary (same key set). */
export const en: Record<DeliverablesKey, string> = {
'produced.label': 'Produced',
'produced.more': '{count} more',
'produced.moreOne': '+ 1 file',
'produced.more': '+ {count} files',
'produced.open': 'Open {name}',
'produced.showInFolder': 'Show in folder',
}
/** Union of this namespace's dictionary keys. */