Merge remote-tracking branch 'origin/master' into fix/stats
This commit is contained in:
@@ -259,6 +259,7 @@ export function apply(ctx: Context): void {
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (): ConversationSessionHeaderInjected => ({
|
||||
@@ -313,9 +314,9 @@ export function apply(ctx: Context): void {
|
||||
return null
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UnsupportedImageMediaTypeError) {
|
||||
return t('image.unsupportedType', {
|
||||
type: error.mediaType || t('image.unknownType'),
|
||||
})
|
||||
// Positive copy: the supported list is fixed in imageMediaType,
|
||||
// and naming it beats echoing the rejected MIME type back.
|
||||
return t('image.unsupportedType')
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -48,34 +49,60 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
|| interrupted === true
|
||||
|| blocks.some(block => block.kind !== 'tool-call')
|
||||
if (!hasVisible) return null
|
||||
const rendered: ReactNode[] = []
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i]
|
||||
if (block === undefined) continue
|
||||
switch (block.kind) {
|
||||
case 'text':
|
||||
rendered.push(
|
||||
<MarkdownText
|
||||
key={i}
|
||||
text={block.text}
|
||||
streaming={streaming}
|
||||
codeLabels={codeLabels}
|
||||
fileMentions={mentions}
|
||||
/>,
|
||||
)
|
||||
break
|
||||
case 'reasoning':
|
||||
rendered.push(<ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />)
|
||||
break
|
||||
case 'image': {
|
||||
// Consecutive image blocks share one gallery so several images tile
|
||||
// into rows instead of each opening a one-image group of its own.
|
||||
// Keyed by the group's FIRST block index: a streaming append that
|
||||
// extends the group then only grows `images` instead of remounting
|
||||
// the gallery under a shifted key.
|
||||
const start = i
|
||||
const group = [block]
|
||||
while (i + 1 < blocks.length) {
|
||||
const next = blocks[i + 1]
|
||||
if (next === undefined || next.kind !== 'image') break
|
||||
group.push(next)
|
||||
i += 1
|
||||
}
|
||||
rendered.push(<ImageGallery key={start} images={group} load={imageLoader} align="start" labels={messageImageLabels(t)} />)
|
||||
break
|
||||
}
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call':
|
||||
break
|
||||
default:
|
||||
rendered.push(
|
||||
<JsonBlock
|
||||
key={i}
|
||||
label={t('message.unknownBlock')}
|
||||
payload={block.block}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
<div className={css.body}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return (
|
||||
<MarkdownText
|
||||
key={i}
|
||||
text={block.text}
|
||||
streaming={streaming}
|
||||
codeLabels={codeLabels}
|
||||
fileMentions={mentions}
|
||||
/>
|
||||
)
|
||||
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
|
||||
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" labels={messageImageLabels(t)} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
case 'tool-call': return null
|
||||
default: return (
|
||||
<JsonBlock
|
||||
key={i}
|
||||
label={t('message.unknownBlock')}
|
||||
payload={block.block}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
{rendered}
|
||||
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shared IconActions chrome for user and assistant messages: copy
|
||||
// live, optional branch wiring, and an optional date-aware clock.
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -29,6 +29,11 @@ export interface MessageIconActionsProps {
|
||||
branchUnavailable?: boolean | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
/**
|
||||
* Slot-rendered actions owned by independent plugins, placed between the
|
||||
* built-in copy and branch controls.
|
||||
*/
|
||||
extraActions?: ReactNode
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -39,7 +44,8 @@ export interface MessageIconActionsProps {
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t,
|
||||
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className,
|
||||
extraActions, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const reasonId = useId()
|
||||
@@ -109,6 +115,7 @@ export function MessageIconActions({
|
||||
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{extraActions}
|
||||
{onBranch !== undefined && (
|
||||
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
|
||||
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
|
||||
|
||||
@@ -244,6 +244,12 @@
|
||||
font: var(--dsw-font-markdown-code-block-small);
|
||||
}
|
||||
|
||||
.maxTokensTitle {
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-state-warn-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes retry-shimmer {
|
||||
from {
|
||||
background-position: 100% 50%;
|
||||
|
||||
@@ -130,6 +130,21 @@ function TurnErrorItem({ node, t }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** Persistent, turn-positioned notice for a turn ended at the output-token cap. */
|
||||
function TurnMaxTokensItem({ t }: {
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
return (
|
||||
<div className={css.turnErrorRow} role="status">
|
||||
<StateDot state="warning" className={css.turnErrorDot} />
|
||||
<div className={css.turnErrorCopy}>
|
||||
<span className={css.maxTokensTitle}>{t('message.maxTokens')}</span>
|
||||
<span className={css.turnErrorMessage}>{t('message.maxTokens.hint')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display projection of reference forms in a user bubble (free geometry — no
|
||||
* textarea alignment constraint here); everything else stays plain text. The
|
||||
@@ -272,6 +287,11 @@ export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: Ch
|
||||
return <TurnErrorItem node={node.data} t={t} />
|
||||
})
|
||||
|
||||
/** Max-tokens turn-end notice keyed Chat renderer. */
|
||||
export const TurnMaxTokensNodeView = memo(function TurnMaxTokensNodeView({ t }: ChatNodeViewProps<'turn-max-tokens'>) {
|
||||
return <TurnMaxTokensItem t={t} />
|
||||
})
|
||||
|
||||
/** Explicit unknown-surface keyed Chat renderer. */
|
||||
export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) {
|
||||
const data = node.data
|
||||
|
||||
@@ -5,11 +5,12 @@ import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { assistantText } from './turn-assistant.ts'
|
||||
import css from './TurnTailNodeView.module.css'
|
||||
|
||||
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'>
|
||||
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
|
||||
& PropsRenderSlots<'conversation.chat.turnTail' | 'conversation.chat.assistant-actions'>
|
||||
|
||||
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
|
||||
export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
node, openFile, forkAt, renderSlotChain, t, useSession,
|
||||
node, openFile, forkAt, renderSlot, renderSlotChain, t, useSession,
|
||||
}: TurnTailNodeViewProps) {
|
||||
const data = node.data
|
||||
const hasLaterChatNode = useSession(snapshot =>
|
||||
@@ -25,6 +26,12 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
const runMs = turn.start === undefined || turn.end === undefined
|
||||
? undefined
|
||||
: Math.max(0, turn.end.time - turn.start.time)
|
||||
// Interruption-frozen partials carry no messageId, so they address no
|
||||
// durable message and contribute no per-message actions.
|
||||
const messageId = closing.finalNode.messageId
|
||||
const assistantActions = messageId === undefined
|
||||
? null
|
||||
: renderSlot('conversation.chat.assistant-actions', { messageId })
|
||||
return (
|
||||
<div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
|
||||
{tail}
|
||||
@@ -38,6 +45,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
onBranch={() => { forkAt(closing.finalNode.seq) }}
|
||||
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
|
||||
className={css.actions}
|
||||
extraActions={assistantActions}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AssistantNodeView } from './AssistantNodeView.tsx'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
|
||||
import {
|
||||
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
|
||||
UnknownNodeView, UserMessageNodeView,
|
||||
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
|
||||
} from './MessageItem.tsx'
|
||||
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
|
||||
|
||||
@@ -35,11 +35,16 @@ export function registerChatNodeRenderers(ctx: Context): void {
|
||||
{ name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'turn-max-tokens', locale: NS }, TurnMaxTokensNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'turn-tail',
|
||||
locale: NS,
|
||||
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
|
||||
children: {
|
||||
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
|
||||
'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
}, TurnTailNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView))
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
TurnLocation, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerBlock } from '../input/blocks.ts'
|
||||
import type {
|
||||
@@ -44,6 +45,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* context that precedes interactive actions.
|
||||
*/
|
||||
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
|
||||
/**
|
||||
* Right-aligned Session utilities kept outside the title-adjacent action
|
||||
* group, so an optional utility cannot reorder session context or lineage.
|
||||
*/
|
||||
'conversation.session.header.utilities': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
|
||||
/**
|
||||
* The conversation view ring: one list entry per view tab (chat here;
|
||||
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
|
||||
@@ -77,6 +83,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* only to return null; an all-declined chain renders nothing.
|
||||
*/
|
||||
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
|
||||
/**
|
||||
* Action strip attached to one finalized assistant message, rendered
|
||||
* inside that message's IconActions row. The chat entry owns the render
|
||||
* site and passes the addressed message identity; contributors add
|
||||
* per-message actions without importing the conversation implementation.
|
||||
* Entries render by ascending `order`.
|
||||
*/
|
||||
'conversation.chat.assistant-actions': {
|
||||
kind: 'list'
|
||||
scope: 'session'
|
||||
owner: AssistantActionOwnerProps
|
||||
}
|
||||
/** Selected Tool call output inside the details panel. */
|
||||
'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
|
||||
/**
|
||||
@@ -253,6 +271,16 @@ export interface TurnTailOwnerProps {
|
||||
openFile: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner currency of the assistant-message action strip: the durable identity
|
||||
* of the one finalized message the contributed actions address. Only finalized
|
||||
* messages reach this slot, so the id is always present.
|
||||
*/
|
||||
export interface AssistantActionOwnerProps {
|
||||
/** Stable identity carried from the `assistant/message` event. */
|
||||
messageId: MessageId
|
||||
}
|
||||
|
||||
/** Hook constrained to business data published on the current Chat Node's Turn. */
|
||||
export type UseChatNodeTurnData = <Key extends Extract<keyof ConversationTurnDataMap, string>>(
|
||||
key: Key,
|
||||
@@ -502,7 +530,7 @@ export type ConversationSessionSlotProps =
|
||||
/** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */
|
||||
export type ConversationSessionHeaderSlotProps =
|
||||
PropsRuntime<'conversation.session.header'>
|
||||
& PropsRenderSlots<'conversation.session.header.actions'>
|
||||
& PropsRenderSlots<'conversation.session.header.actions' | 'conversation.session.header.utilities'>
|
||||
& PropsStore<ChatStore>
|
||||
& ConversationSessionHeaderInjected
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
@@ -152,6 +152,7 @@ function finalNode(
|
||||
return {
|
||||
kind: 'assistant',
|
||||
seq: event.seq,
|
||||
messageId: event.data.message.id,
|
||||
time: event.time,
|
||||
turn: state.turn,
|
||||
step: state.step,
|
||||
|
||||
@@ -165,6 +165,7 @@ function legacyContribution(raw: ChatConversationViewNode): LegacyContribution {
|
||||
case 'command':
|
||||
case 'compaction':
|
||||
case 'turn-error':
|
||||
case 'turn-max-tokens':
|
||||
case 'unknown':
|
||||
return { anchorSeq: node.anchorSeq, nodes: [node.data], partial: null, running: null }
|
||||
case 'assistant-step': {
|
||||
|
||||
@@ -7,11 +7,14 @@ import type {
|
||||
|
||||
/**
|
||||
* Relative positions in one durable event's seq neighborhood: interrupted
|
||||
* Assistant, its follow-up Nodes, then follow-ups to an ordinary final.
|
||||
* Assistant, its follow-up Nodes, then follow-ups to an ordinary final. The
|
||||
* max-tokens notice sits between a closing Assistant and the turn-tail so the
|
||||
* tail stays the turn's last node and keeps its branch action enabled.
|
||||
*/
|
||||
export const CHAT_SYNTHETIC_SEQ_OFFSETS = {
|
||||
interruptedAssistant: -0.9,
|
||||
interruptedFollowup: -0.8,
|
||||
maxTokensNotice: 0.05,
|
||||
finalizedFollowup: 0.1,
|
||||
} as const
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { registerMessageConversationNode } from './message.ts'
|
||||
import { registerRetryConversationNode } from './retry.ts'
|
||||
import { registerToolConversationNode } from './tool.ts'
|
||||
import { registerTurnErrorConversationNode } from './turn-error.ts'
|
||||
import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts'
|
||||
import { registerTurnTailConversationNode } from './turn-tail.ts'
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ export function registerConversationNodes(ctx: Context): void {
|
||||
registerCompactionConversationNode(ctx)
|
||||
registerRetryConversationNode(ctx)
|
||||
registerTurnErrorConversationNode(ctx)
|
||||
registerTurnMaxTokensConversationNode(ctx)
|
||||
registerTurnTailConversationNode(ctx)
|
||||
registerUnknownConversationFallback(ctx)
|
||||
registerChatConversationView(ctx)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Turn ended by the per-request output-token cap. */
|
||||
'turn-max-tokens': TurnMaxTokensNode
|
||||
}
|
||||
}
|
||||
|
||||
interface TurnMaxTokensState {
|
||||
readonly turn: number
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
}
|
||||
|
||||
function lastStep(context: ConversationNodeContext<TurnMaxTokensState>): number {
|
||||
const location = context.start?.location ?? context.matches[0]?.location
|
||||
if (location?.kind !== 'turn' && location?.kind !== 'step') return 0
|
||||
return location.turn.steps.at(-1)?.step ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor the notice between the closing Assistant and the turn-tail so the
|
||||
* tail stays the turn's last Chat node and keeps its branch action enabled.
|
||||
* Without a closing text Assistant there is no branch action to protect, and
|
||||
* the turn/end seq keeps the notice at the truncation point.
|
||||
*/
|
||||
function noticeAnchor(context: ConversationNodeContext<TurnMaxTokensState>, seq: number): number {
|
||||
const location = context.start?.location ?? context.matches[0]?.location
|
||||
if (location?.kind !== 'turn' && location?.kind !== 'step') return seq
|
||||
const closing = location.turn.data.get('turn-tail')?.closing
|
||||
return closing === null || closing === undefined
|
||||
? seq
|
||||
: closing.finalNode.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.maxTokensNotice
|
||||
}
|
||||
|
||||
function stateFrom(match: ConversationMatch): TurnMaxTokensState | undefined {
|
||||
if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'max-tokens') return undefined
|
||||
return { turn: match.event.data.turn, seq: match.event.seq, time: match.event.time }
|
||||
}
|
||||
|
||||
/** Notice Definition for a turn the provider ended at its output-token cap. */
|
||||
export const turnMaxTokensDefinition: ConversationNodeDefinition<TurnMaxTokensState> = {
|
||||
kind: 'turn-max-tokens',
|
||||
target: 'chat',
|
||||
match: (event) => {
|
||||
if (event.type === 'turn/end' && event.data.reason.kind === 'max-tokens') {
|
||||
return { id: String(event.data.turn), role: 'start' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => {
|
||||
const state = stateFrom(match)
|
||||
if (state === undefined) throw new Error('turn-max-tokens start requires a max-tokens turn/end')
|
||||
return state
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: (context) => {
|
||||
const state = context.state
|
||||
if (state === undefined) return null
|
||||
const node: TurnMaxTokensNode = {
|
||||
kind: 'turn-max-tokens',
|
||||
seq: state.seq,
|
||||
time: state.time,
|
||||
turn: state.turn,
|
||||
step: lastStep(context),
|
||||
}
|
||||
return chatNode(context, 'turn-max-tokens', noticeAnchor(context, state.seq), node)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the max-tokens turn-end notice contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerTurnMaxTokensConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(turnMaxTokensDefinition)
|
||||
}
|
||||
@@ -3,11 +3,60 @@
|
||||
* application state; owners resolve every string). */
|
||||
|
||||
import type {
|
||||
AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels,
|
||||
AttachmentRailLabels, DropOverlayLabels, ImageLightboxLabels, MessageImageLabels,
|
||||
} from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConversationKey } from './locales.ts'
|
||||
|
||||
/**
|
||||
* Byte count as user-facing megabytes (`10MB`, `2.5MB`).
|
||||
* @param bytes - the byte count.
|
||||
* @returns the rounded megabyte text.
|
||||
*/
|
||||
export function imageSizeText(bytes: number): string {
|
||||
const mb = bytes / (1024 * 1024)
|
||||
return `${Number.isInteger(mb) ? String(mb) : mb.toFixed(1)}MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* Product copy for a host attachment rejection (the `attachment-error`
|
||||
* `details.reason`). User-solvable reasons name the limit and the way out;
|
||||
* reasons the user cannot act on fold into one send-failed line carrying the
|
||||
* reason code for a bug report.
|
||||
* @param t - the conversation-namespace translate.
|
||||
* @param reason - the wire `details.reason` code.
|
||||
* @param limits - projected limits interpolated into count/size copy, when known.
|
||||
* @returns the banner text.
|
||||
*/
|
||||
export function attachmentErrorText(
|
||||
t: Translate<ConversationKey>,
|
||||
reason: string,
|
||||
limits?: ImageAttachmentLimits,
|
||||
): string {
|
||||
switch (reason) {
|
||||
case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported')
|
||||
case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported')
|
||||
case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels')
|
||||
// Undecodable bytes or a declared type its bytes contradict: solvable by
|
||||
// replacing or re-exporting the file, so it reads as a format problem.
|
||||
case 'INVALID_IMAGE':
|
||||
case 'IMAGE_TYPE_MISMATCH':
|
||||
return t('image.unsupportedType')
|
||||
case 'TOO_MANY_IMAGES':
|
||||
if (limits !== undefined) return t('image.tooMany', { count: limits.maxImagesPerMessage })
|
||||
break
|
||||
case 'IMAGE_TOO_LARGE':
|
||||
if (limits !== undefined) return t('image.fileTooLarge', { size: imageSizeText(limits.maxImageBytes) })
|
||||
break
|
||||
case 'IMAGES_TOO_LARGE':
|
||||
if (limits !== undefined) return t('image.totalTooLarge', { size: imageSizeText(limits.maxMessageImageBytes) })
|
||||
break
|
||||
default: break
|
||||
}
|
||||
return t('image.sendFailed', { reason })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the original-image lightbox strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
@@ -33,6 +82,25 @@ export function messageImageLabels(t: Translate<ConversationKey>): MessageImageL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full-page drop overlay strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
* @param accepting - whether drops are currently accepted.
|
||||
* @param limits - per-message limits for the desc line, when known.
|
||||
* @returns the overlay title, with the limits desc while accepting.
|
||||
*/
|
||||
export function dropOverlayLabels(
|
||||
t: Translate<ConversationKey>,
|
||||
accepting: boolean,
|
||||
limits?: { count: number; size: string },
|
||||
): DropOverlayLabels {
|
||||
if (!accepting) return { title: t('image.dropBlocked') }
|
||||
return {
|
||||
title: t('image.dropTitle'),
|
||||
desc: limits === undefined ? undefined : t('image.dropDesc', { count: limits.count, size: limits.size }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the composer draft-image rail strings.
|
||||
* @param t - the conversation-namespace translate.
|
||||
|
||||
@@ -11,6 +11,7 @@ export type {} from './conversation-nodes/message.ts'
|
||||
export type {} from './conversation-nodes/retry.ts'
|
||||
export type {} from './conversation-nodes/tool.ts'
|
||||
export type {} from './conversation-nodes/turn-error.ts'
|
||||
export type {} from './conversation-nodes/turn-max-tokens.ts'
|
||||
export type {} from './conversation-nodes/turn-tail.ts'
|
||||
|
||||
export { apply, inject } from './apply.ts'
|
||||
|
||||
@@ -25,7 +25,9 @@ export const zh = {
|
||||
'input.send': '发送消息',
|
||||
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
|
||||
'input.accessMode': '访问模式,当前:{name}',
|
||||
'image.dropHint': '松开以添加图片',
|
||||
'image.dropTitle': '图片拖动到此处即可添加',
|
||||
'image.dropDesc': '最多 {count} 张,每张 {size}',
|
||||
'image.dropBlocked': '当前无法添加图片',
|
||||
'image.pending': '待发送图片',
|
||||
'image.openOriginal': '查看原图',
|
||||
'image.openOriginalLabel': '{label},点击查看原图',
|
||||
@@ -39,8 +41,14 @@ export const zh = {
|
||||
'image.preview': '原图预览',
|
||||
'image.closePreview': '关闭原图预览',
|
||||
'image.serviceUnavailable': '图片读取服务不可用',
|
||||
'image.unsupportedType': '不支持的图片格式:{type}',
|
||||
'image.unknownType': '未知格式',
|
||||
'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片',
|
||||
'image.tooMany': '一条消息最多添加 {count} 张图片',
|
||||
'image.fileTooLarge': '单张图片不能超过 {size}',
|
||||
'image.totalTooLarge': '图片总大小超过 {size},请移除部分图片',
|
||||
'image.tooManyPixels': '图片分辨率过大,请压缩后重试',
|
||||
'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
|
||||
'image.subagentUnsupported': '子智能体会话暂不支持图片',
|
||||
'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
|
||||
'context.aria': '上下文已用 {percent}',
|
||||
'context.used': '上下文已用',
|
||||
'context.system': '系统提示词',
|
||||
@@ -114,6 +122,8 @@ export const zh = {
|
||||
'message.retry.delay': '重试延迟:',
|
||||
'message.retry.failure': '失败原因:',
|
||||
'message.turnError': '本轮运行失败',
|
||||
'message.maxTokens': '已达到输出 token 上限',
|
||||
'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
|
||||
'message.ranFor': '用时 {duration}',
|
||||
'message.ttft': '首 token {seconds}秒',
|
||||
'message.tokensPerSecond': '{tps} tok/s',
|
||||
@@ -184,7 +194,9 @@ export const en = {
|
||||
'input.send': 'Send message',
|
||||
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
|
||||
'input.accessMode': 'Access mode, current: {name}',
|
||||
'image.dropHint': 'Drop to add images',
|
||||
'image.dropTitle': 'Drag images here to add them',
|
||||
'image.dropDesc': 'Up to {count} images, {size} each',
|
||||
'image.dropBlocked': 'Images cannot be added right now',
|
||||
'image.pending': 'Pending images',
|
||||
'image.openOriginal': 'View original',
|
||||
'image.openOriginalLabel': '{label}, click to view original',
|
||||
@@ -198,8 +210,14 @@ export const en = {
|
||||
'image.preview': 'Original image preview',
|
||||
'image.closePreview': 'Close original image preview',
|
||||
'image.serviceUnavailable': 'Image loading service unavailable',
|
||||
'image.unsupportedType': 'Unsupported image format: {type}',
|
||||
'image.unknownType': 'unknown format',
|
||||
'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported',
|
||||
'image.tooMany': 'A message can include up to {count} images',
|
||||
'image.fileTooLarge': 'Each image must be smaller than {size}',
|
||||
'image.totalTooLarge': 'Images exceed {size} in total; remove some and try again',
|
||||
'image.tooManyPixels': 'Image resolution is too high; compress it and try again',
|
||||
'image.modelUnsupported': 'The current model does not support images; switch to a model that does',
|
||||
'image.subagentUnsupported': 'Subagent sessions do not support images yet',
|
||||
'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',
|
||||
'context.aria': '{percent} of context used',
|
||||
'context.used': 'of context used',
|
||||
'context.system': 'System prompt',
|
||||
@@ -273,6 +291,8 @@ export const en = {
|
||||
'message.retry.delay': 'Retry delay: ',
|
||||
'message.retry.failure': 'Failure reason: ',
|
||||
'message.turnError': 'This turn failed',
|
||||
'message.maxTokens': 'Output token limit reached',
|
||||
'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.',
|
||||
'message.ranFor': 'Ran for {duration}',
|
||||
'message.ttft': 'TTFT {seconds}s',
|
||||
'message.tokensPerSecond': '{tps} tok/s',
|
||||
|
||||
@@ -53,9 +53,18 @@
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.titleCluster {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -111,6 +120,18 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headerUtilities {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.headerUtilities:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
|
||||
.tabs {
|
||||
position: relative;
|
||||
|
||||
@@ -69,27 +69,32 @@ export function ConversationSessionHeader({
|
||||
{!hideChrome && (
|
||||
<>
|
||||
<div className={css.titleRow}>
|
||||
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
|
||||
{ancestry.map((summary, index) => {
|
||||
const last = index === ancestry.length - 1
|
||||
return (
|
||||
<span key={summary.id} className={css.crumbSeg}>
|
||||
{index > 0 && <span className={css.crumbSep}>/</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.crumb, last && css.crumbCurrent)}
|
||||
disabled={last}
|
||||
onClick={() => { open(summary.id) }}
|
||||
>
|
||||
{summary.displayTitle}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
</nav>
|
||||
<div className={css.headerActions}>
|
||||
{renderSlot('conversation.session.header.actions', {})}
|
||||
<div className={css.titleCluster}>
|
||||
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
|
||||
{ancestry.map((summary, index) => {
|
||||
const last = index === ancestry.length - 1
|
||||
return (
|
||||
<span key={summary.id} className={css.crumbSeg}>
|
||||
{index > 0 && <span className={css.crumbSep}>/</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.crumb, last && css.crumbCurrent)}
|
||||
disabled={last}
|
||||
onClick={() => { open(summary.id) }}
|
||||
>
|
||||
{summary.displayTitle}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
</nav>
|
||||
<div className={css.headerActions}>
|
||||
{renderSlot('conversation.session.header.actions', {})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.headerUtilities}>
|
||||
{renderSlot('conversation.session.header.utilities', {})}
|
||||
</div>
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
|
||||
@@ -115,25 +115,6 @@
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.dragActive {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2);
|
||||
}
|
||||
|
||||
.dropHint {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary));
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.accessory {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,23 +7,28 @@
|
||||
* (running/removed/promptError) are self-selected via useSession. */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import { AttachmentRail, DropOverlay, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment'
|
||||
// Type-only: the `plan` projection key merge (the TodoDock posture — the
|
||||
// composer reads a host-computed value; the domain owns the key).
|
||||
import type {} from '@deepseek-ai/dsh-plan-mode/client'
|
||||
// Type-only: the `goal` projection key merge (hint disambiguation).
|
||||
import type {} from '@deepseek-ai/dsh-goal/client'
|
||||
// The `imageLimits` projection key merge (intake pre-check) arrives with the
|
||||
// wire types: apiproxy's sessions contract declares it, and client-runtime's
|
||||
// api-remotes import already places it in every client program.
|
||||
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
|
||||
import { deriveDecorations } from '../input/decorations.ts'
|
||||
import type { DraftDecorations } from '../input/decorations.ts'
|
||||
import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts'
|
||||
import {
|
||||
attachmentErrorText, attachmentRailLabels, dropOverlayLabels, imageSizeText, lightboxLabels,
|
||||
} from '../image-labels.ts'
|
||||
import { ContextMeter } from './ContextMeter.tsx'
|
||||
import { PermissionSelect } from './PermissionSelect.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
@@ -80,14 +85,22 @@ export function InputBar({
|
||||
setToast({ seq: toastSeq.current, text })
|
||||
}, [])
|
||||
const dismissToast = useCallback(() => { setToast(null) }, [])
|
||||
// The deployment's image-intake limits (absent while no attachment service
|
||||
// is composed — the pre-check below then defers entirely to the host).
|
||||
const imageLimits = useProjection('imageLimits')
|
||||
// Prompt failures are ordinary failures (no create/attach transaction exists
|
||||
// anymore): the toast announces promptError, the draft stays in the machine,
|
||||
// and the user resubmits. A remount over a session whose machine still holds
|
||||
// an unresolved promptError deliberately re-announces it once — the failure
|
||||
// is still pending, and a transient banner is its only surface.
|
||||
// is still pending, and a transient banner is its only surface. Attachment
|
||||
// rejections show product copy keyed by the wire reason; other codes are
|
||||
// developer-facing and keep the raw message plus code.
|
||||
useEffect(() => {
|
||||
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
|
||||
}, [promptError, showToast])
|
||||
if (promptError === null) return
|
||||
showToast(promptError.error.code === 'attachment-error'
|
||||
? attachmentErrorText(t, promptError.error.details.reason, imageLimits)
|
||||
: `${promptError.error.message} (${promptError.error.code})`)
|
||||
}, [promptError, showToast, t, imageLimits])
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const cardRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
@@ -384,10 +397,7 @@ export function InputBar({
|
||||
.filter(item => item.kind === 'file')
|
||||
.map(item => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
if (files.length > 0 && addImages !== undefined) {
|
||||
const rejected = addImages(files)
|
||||
if (rejected !== null) showToast(rejected)
|
||||
}
|
||||
if (files.length > 0) intakeImages(files)
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
if (text === '') {
|
||||
if (files.length > 0) e.preventDefault()
|
||||
@@ -406,38 +416,94 @@ export function InputBar({
|
||||
keyboard.track(keyboard.snapshot.draft, caret)
|
||||
}
|
||||
|
||||
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
if (locked || machineBusy || addImages === undefined) return
|
||||
dragDepthRef.current += 1
|
||||
setDragActive(true)
|
||||
}
|
||||
// Intake pre-check (DeepSeek Chat semantics): an addition that would break
|
||||
// a projected limit is refused as a whole batch, announced immediately, and
|
||||
// never enters the rail — no more submit-time failure rolling the rail
|
||||
// back. The host enforces the same limits at submit for callers that bypass
|
||||
// this composer.
|
||||
const intakeImages = useCallback((files: readonly File[]): void => {
|
||||
if (addImages === undefined || files.length === 0) return
|
||||
const rejected = ((): string | null => {
|
||||
if (imageLimits !== undefined) {
|
||||
// Format precedes limits (DeepSeek Chat's filter order): a batch with
|
||||
// a non-image must announce the format problem, not a count or size
|
||||
// it could never pass anyway — addImages rejects it authoritatively.
|
||||
if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) {
|
||||
return addImages(files)
|
||||
}
|
||||
if (attachments.length + files.length > imageLimits.maxImagesPerMessage) {
|
||||
return t('image.tooMany', { count: imageLimits.maxImagesPerMessage })
|
||||
}
|
||||
if (files.some(file => file.size > imageLimits.maxImageBytes)) {
|
||||
return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) })
|
||||
}
|
||||
const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0)
|
||||
+ files.reduce((sum, file) => sum + file.size, 0)
|
||||
if (total > imageLimits.maxMessageImageBytes) {
|
||||
return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) })
|
||||
}
|
||||
}
|
||||
return addImages(files)
|
||||
})()
|
||||
if (rejected !== null) showToast(rejected)
|
||||
}, [addImages, attachments, imageLimits, showToast, t])
|
||||
|
||||
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy'
|
||||
}
|
||||
|
||||
const onDragLeave = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files') || locked || machineBusy) return
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) setDragActive(false)
|
||||
}
|
||||
|
||||
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
if (locked || machineBusy || addImages === undefined) return
|
||||
const dropped = [...event.dataTransfer.files]
|
||||
if (dropped.length > 0) {
|
||||
const rejected = addImages(dropped)
|
||||
if (rejected !== null) showToast(rejected)
|
||||
// Whole-page file-drop intake (DeepSeek Chat behavior): the listeners live
|
||||
// on the document so a drop anywhere over the window adds images, not only
|
||||
// over the composer card. Safe as document-level state: the composer-bar
|
||||
// slot is `kind: 'single'`, so at most one bar is mounted to bind these.
|
||||
// Text drags carry no 'Files' type and pass through untouched, keeping the
|
||||
// native drop-text-into-textarea path. The overlay layer itself is
|
||||
// pointer-inert, so it never disturbs the enter/leave count.
|
||||
const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
|
||||
useEffect(() => {
|
||||
const hasFiles = (event: globalThis.DragEvent): boolean =>
|
||||
event.dataTransfer?.types.includes('Files') ?? false
|
||||
const reset = (): void => {
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
}
|
||||
}
|
||||
const onDragEnter = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event)) return
|
||||
event.preventDefault()
|
||||
dragDepthRef.current += 1
|
||||
setDragActive(true)
|
||||
}
|
||||
const onDragOver = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event) || event.dataTransfer === null) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
|
||||
}
|
||||
const onDragLeave = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event)) return
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) setDragActive(false)
|
||||
// Leaving through the viewport edge does not balance the count on every
|
||||
// engine; a page-root leave at the border means the drag left the window.
|
||||
const leavingViewport = event.clientX <= 0 || event.clientY <= 0
|
||||
|| event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
|
||||
if ((event.target === document.documentElement || event.target === document.body) && leavingViewport) reset()
|
||||
}
|
||||
const onDrop = (event: globalThis.DragEvent): void => {
|
||||
if (!hasFiles(event)) return
|
||||
event.preventDefault()
|
||||
reset()
|
||||
if (!canAcceptDrop) return
|
||||
intakeImages([...(event.dataTransfer?.files ?? [])])
|
||||
}
|
||||
document.addEventListener('dragenter', onDragEnter)
|
||||
document.addEventListener('dragover', onDragOver)
|
||||
document.addEventListener('dragleave', onDragLeave)
|
||||
document.addEventListener('drop', onDrop)
|
||||
window.addEventListener('dragend', reset)
|
||||
return () => {
|
||||
document.removeEventListener('dragenter', onDragEnter)
|
||||
document.removeEventListener('dragover', onDragOver)
|
||||
document.removeEventListener('dragleave', onDragLeave)
|
||||
document.removeEventListener('drop', onDrop)
|
||||
window.removeEventListener('dragend', reset)
|
||||
}
|
||||
}, [canAcceptDrop, intakeImages])
|
||||
|
||||
const closePreview = useCallback(() => { setPreview(null) }, [])
|
||||
|
||||
@@ -573,6 +639,15 @@ export function InputBar({
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
|
||||
{dragActive && (
|
||||
<DropOverlay
|
||||
disabled={!canAcceptDrop}
|
||||
labels={dropOverlayLabels(t, canAcceptDrop, imageLimits === undefined ? undefined : {
|
||||
count: imageLimits.maxImagesPerMessage,
|
||||
size: imageSizeText(imageLimits.maxImageBytes),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{toast !== null && (
|
||||
<Toast
|
||||
key={toast.seq}
|
||||
@@ -594,16 +669,11 @@ export function InputBar({
|
||||
click's reopen (close-then-open flickers the chip's open echo). */}
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
|
||||
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger)}
|
||||
data-composer-card
|
||||
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
|
||||
onPointerDown={workspaceTrigger ? (e) => { e.stopPropagation() } : undefined}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
|
||||
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
|
||||
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
|
||||
{railItems.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user