feat(web): make produced-file overflow discoverable
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user