|
|
@@ -1,12 +1,13 @@
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
|
import { useEffect, useId, useMemo, useState } from 'react'
|
|
|
+import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|
|
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
|
import {
|
|
|
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16,
|
|
|
IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip,
|
|
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
-import type { QueueAction, QueueItemId } from '../contract/queue.ts'
|
|
|
+import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts'
|
|
|
import { NS } from '../locales.ts'
|
|
|
import css from './QueueDock.module.css'
|
|
|
|
|
|
@@ -14,6 +15,43 @@ import css from './QueueDock.module.css'
|
|
|
export interface QueueDockInjected {
|
|
|
updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise<void>
|
|
|
notify: (level: 'info' | 'error', text: string) => void
|
|
|
+ /** Resolve one durable queued image into a session-scoped browser URL. */
|
|
|
+ loadImage: (attachment: ImageAttachmentRef) => Promise<string>
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Durable references carried by one queued row. Queue frames are wire data
|
|
|
+ * despite their typed face, so an image block without a reference is skipped
|
|
|
+ * rather than trusted.
|
|
|
+ * @param content - the row's wire content blocks.
|
|
|
+ * @returns the row's durable image references in block order.
|
|
|
+ */
|
|
|
+function queueImageRefs(content: QueueRow['content']): ImageAttachmentRef[] {
|
|
|
+ return content.flatMap((block) => {
|
|
|
+ if (block.type !== 'image') return []
|
|
|
+ const { attachment } = block as { attachment?: ImageAttachmentRef }
|
|
|
+ return attachment === undefined ? [] : [attachment]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** One durable queued image as a fixed-size thumbnail; a load failure keeps the empty placeholder. */
|
|
|
+function QueueThumb({ attachment, loadImage, label }: {
|
|
|
+ attachment: ImageAttachmentRef
|
|
|
+ loadImage: QueueDockInjected['loadImage']
|
|
|
+ label: string
|
|
|
+}) {
|
|
|
+ const [url, setUrl] = useState<string | null>(null)
|
|
|
+ useEffect(() => {
|
|
|
+ let alive = true
|
|
|
+ loadImage(attachment).then(
|
|
|
+ (resolved) => { if (alive) setUrl(resolved) },
|
|
|
+ () => { /* placeholder retained; the durable transcript surfaces read errors */ },
|
|
|
+ )
|
|
|
+ return () => { alive = false }
|
|
|
+ }, [attachment, loadImage])
|
|
|
+ return url === null
|
|
|
+ ? <span className={css.thumb} aria-hidden />
|
|
|
+ : <img className={css.thumb} src={url} alt={label} />
|
|
|
}
|
|
|
|
|
|
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat + the locale seat. */
|
|
|
@@ -23,9 +61,17 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock
|
|
|
* Queue strip: one item renders directly; multiple items default to a
|
|
|
* collapsible count header; an empty queue renders nothing.
|
|
|
*/
|
|
|
-export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) {
|
|
|
+export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: QueueDockProps) {
|
|
|
const inbox = useSession(s => s.queue)
|
|
|
const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox])
|
|
|
+ const pendingSubmissions = useSession(s => s.pendingSubmissions)
|
|
|
+ const pendingQueue = useMemo(() => {
|
|
|
+ const admitted = new Set(queue.flatMap(row => row.rpcId === undefined ? [] : [row.rpcId]))
|
|
|
+ return pendingSubmissions.filter(submission => (
|
|
|
+ submission.placement === 'queued' && !admitted.has(submission.requestId)
|
|
|
+ ))
|
|
|
+ }, [pendingSubmissions, queue])
|
|
|
+ const rowCount = queue.length + pendingQueue.length
|
|
|
const running = useSession(s => s.running)
|
|
|
const queueMutable = useSession(s => s.subagent === null)
|
|
|
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
|
|
|
@@ -34,15 +80,15 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
|
|
const listId = useId()
|
|
|
|
|
|
useEffect(() => {
|
|
|
- if (queue.length === 0 && !collapsed) setCollapsed(true)
|
|
|
+ if (rowCount === 0 && !collapsed) setCollapsed(true)
|
|
|
if (editing !== null && (!queueMutable || !queue.some(row => row.id === editing.id))) setEditing(null)
|
|
|
- }, [collapsed, editing, queue, queueMutable])
|
|
|
+ }, [collapsed, editing, queue, queueMutable, rowCount])
|
|
|
|
|
|
- if (queue.length === 0) return null
|
|
|
+ if (rowCount === 0) return null
|
|
|
|
|
|
const interactionActive = queueMutable && (editing !== null || busy !== null)
|
|
|
const expanded = !collapsed || interactionActive
|
|
|
- const listVisible = queue.length === 1 || expanded
|
|
|
+ const listVisible = rowCount === 1 || expanded
|
|
|
|
|
|
const applyAction = async (
|
|
|
itemId: QueueItemId,
|
|
|
@@ -73,7 +119,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
|
|
return (
|
|
|
<div className={css.dock} data-queue-dock="">
|
|
|
<div className={css.panel}>
|
|
|
- {queue.length > 1 && (
|
|
|
+ {rowCount > 1 && (
|
|
|
<button
|
|
|
type="button"
|
|
|
className={css.header}
|
|
|
@@ -83,122 +129,159 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
|
|
onClick={() => { setCollapsed(value => !value) }}
|
|
|
>
|
|
|
<span className={css.lead} aria-hidden><IconQueueOutline14 /></span>
|
|
|
- <span className={css.count}>{t('queue.count', { n: queue.length })}</span>
|
|
|
+ <span className={css.count}>{t('queue.count', { n: rowCount })}</span>
|
|
|
<span className={css.chevron} aria-hidden>
|
|
|
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
|
|
|
</span>
|
|
|
</button>
|
|
|
)}
|
|
|
<ul id={listId} className={css.list} hidden={!listVisible}>
|
|
|
- {listVisible && queue.map(row => (
|
|
|
- <li key={row.id} className={css.row}>
|
|
|
- {/* Single-item strip has no count header, so the row itself carries the queue glyph. */}
|
|
|
- {queue.length === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
|
|
|
- {editing?.id === row.id
|
|
|
- ? (
|
|
|
- <input
|
|
|
- autoFocus
|
|
|
- className={css.editor}
|
|
|
- aria-label={t('queue.edit')}
|
|
|
- value={editing.text}
|
|
|
- onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
|
|
|
- onKeyDown={(event) => {
|
|
|
- if (event.key === 'Escape') {
|
|
|
- setEditing(null)
|
|
|
- return
|
|
|
- }
|
|
|
- if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
|
|
- event.preventDefault()
|
|
|
- void saveEdit()
|
|
|
- }
|
|
|
- }}
|
|
|
- />
|
|
|
- )
|
|
|
- : <span className={css.preview}>{projectUserText(row.preview, [])}</span>}
|
|
|
- {queueMutable && <div className={css.actions}>
|
|
|
+ {listVisible && queue.map((row) => {
|
|
|
+ const imageRefs = queueImageRefs(row.content)
|
|
|
+ return (
|
|
|
+ <li key={row.id} className={css.row}>
|
|
|
+ {/* Single-item strip has no count header, so the row itself carries the queue glyph. */}
|
|
|
+ {rowCount === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
|
|
|
{editing?.id === row.id
|
|
|
? (
|
|
|
- <>
|
|
|
- <Tooltip label={t('queue.save')} side="bottom" delayMs={500}>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className={css.action}
|
|
|
- aria-label={t('queue.save')}
|
|
|
- disabled={busy !== null || editing.text.trim() === ''}
|
|
|
- onClick={() => { void saveEdit() }}
|
|
|
- >
|
|
|
- <IconCheckOutline16 size={14} />
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- <Tooltip label={t('queue.cancelEdit')} side="bottom" delayMs={500}>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className={css.action}
|
|
|
- aria-label={t('queue.cancelEdit')}
|
|
|
- disabled={busy !== null}
|
|
|
- onClick={() => { setEditing(null) }}
|
|
|
- >
|
|
|
- <IconCloseOutline16 size={14} />
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- </>
|
|
|
+ <input
|
|
|
+ autoFocus
|
|
|
+ className={css.editor}
|
|
|
+ aria-label={t('queue.edit')}
|
|
|
+ value={editing.text}
|
|
|
+ onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
|
|
|
+ onKeyDown={(event) => {
|
|
|
+ if (event.key === 'Escape') {
|
|
|
+ setEditing(null)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
|
|
+ event.preventDefault()
|
|
|
+ void saveEdit()
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
)
|
|
|
: (
|
|
|
<>
|
|
|
- <Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={row.text === null}>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className={css.action}
|
|
|
- aria-label={t('queue.edit')}
|
|
|
- // Disabled buttons fire no hover events, so the
|
|
|
- // unsupported hint stays a native title.
|
|
|
- title={row.text === null ? t('queue.edit.unsupported') : undefined}
|
|
|
- disabled={busy !== null || row.text === null}
|
|
|
- onClick={() => {
|
|
|
- if (row.text !== null) setEditing({ id: row.id, text: row.text })
|
|
|
- }}
|
|
|
- >
|
|
|
- <IconEditOutline16 size={14} />
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- <Tooltip label={t('queue.remove')} side="bottom" delayMs={500}>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className={css.action}
|
|
|
- aria-label={t('queue.remove')}
|
|
|
- disabled={busy !== null}
|
|
|
- onClick={() => {
|
|
|
- void applyAction(
|
|
|
- row.id,
|
|
|
- { kind: 'remove' },
|
|
|
- t('queue.removeFailed'),
|
|
|
- )
|
|
|
- }}
|
|
|
- >
|
|
|
- <IconTrashOutline16 size={14} />
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
- <Tooltip label={t('queue.steer')} side="bottom" delayMs={500} disabled={!running}>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- className={css.action}
|
|
|
- aria-label={t('queue.steer')}
|
|
|
- title={running ? undefined : t('queue.steer.unavailable')}
|
|
|
- disabled={busy !== null || !running}
|
|
|
- onClick={() => {
|
|
|
- void applyAction(
|
|
|
- row.id,
|
|
|
- { kind: 'steer' },
|
|
|
- t('queue.steerFailed'),
|
|
|
- )
|
|
|
- }}
|
|
|
- >
|
|
|
- <IconSendOutline14 />
|
|
|
- </button>
|
|
|
- </Tooltip>
|
|
|
+ {imageRefs.length > 0 && (
|
|
|
+ <span className={css.thumbs}>
|
|
|
+ {imageRefs.map((attachment, index) => (
|
|
|
+ <QueueThumb
|
|
|
+ key={`${attachment.attachmentId}:${index}`}
|
|
|
+ attachment={attachment}
|
|
|
+ loadImage={loadImage}
|
|
|
+ label={t('queue.image')}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ <span className={css.preview}>{projectUserText(row.preview, [])}</span>
|
|
|
</>
|
|
|
)}
|
|
|
- </div>}
|
|
|
+ {queueMutable && <div className={css.actions}>
|
|
|
+ {editing?.id === row.id
|
|
|
+ ? (
|
|
|
+ <>
|
|
|
+ <Tooltip label={t('queue.save')} side="bottom" delayMs={500}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.action}
|
|
|
+ aria-label={t('queue.save')}
|
|
|
+ disabled={busy !== null || editing.text.trim() === ''}
|
|
|
+ onClick={() => { void saveEdit() }}
|
|
|
+ >
|
|
|
+ <IconCheckOutline16 size={14} />
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ <Tooltip label={t('queue.cancelEdit')} side="bottom" delayMs={500}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.action}
|
|
|
+ aria-label={t('queue.cancelEdit')}
|
|
|
+ disabled={busy !== null}
|
|
|
+ onClick={() => { setEditing(null) }}
|
|
|
+ >
|
|
|
+ <IconCloseOutline16 size={14} />
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ </>
|
|
|
+ )
|
|
|
+ : (
|
|
|
+ <>
|
|
|
+ <Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={row.text === null}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.action}
|
|
|
+ aria-label={t('queue.edit')}
|
|
|
+ // Disabled buttons fire no hover events, so the
|
|
|
+ // unsupported hint stays a native title.
|
|
|
+ title={row.text === null ? t('queue.edit.unsupported') : undefined}
|
|
|
+ disabled={busy !== null || row.text === null}
|
|
|
+ onClick={() => {
|
|
|
+ if (row.text !== null) setEditing({ id: row.id, text: row.text })
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <IconEditOutline16 size={14} />
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ <Tooltip label={t('queue.remove')} side="bottom" delayMs={500}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.action}
|
|
|
+ aria-label={t('queue.remove')}
|
|
|
+ disabled={busy !== null}
|
|
|
+ onClick={() => {
|
|
|
+ void applyAction(
|
|
|
+ row.id,
|
|
|
+ { kind: 'remove' },
|
|
|
+ t('queue.removeFailed'),
|
|
|
+ )
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <IconTrashOutline16 size={14} />
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ <Tooltip label={t('queue.steer')} side="bottom" delayMs={500} disabled={!running}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.action}
|
|
|
+ aria-label={t('queue.steer')}
|
|
|
+ title={running ? undefined : t('queue.steer.unavailable')}
|
|
|
+ disabled={busy !== null || !running}
|
|
|
+ onClick={() => {
|
|
|
+ void applyAction(
|
|
|
+ row.id,
|
|
|
+ { kind: 'steer' },
|
|
|
+ t('queue.steerFailed'),
|
|
|
+ )
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <IconSendOutline14 />
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </div>}
|
|
|
+ </li>
|
|
|
+ )
|
|
|
+ })}
|
|
|
+ {listVisible && pendingQueue.map(submission => (
|
|
|
+ <li key={submission.requestId} className={css.row} data-submission-echo="">
|
|
|
+ {rowCount === 1 && <span className={css.lead} aria-hidden><IconQueueOutline14 /></span>}
|
|
|
+ {submission.images.length > 0 && (
|
|
|
+ <span className={css.thumbs}>
|
|
|
+ {submission.images.map((image, index) => (
|
|
|
+ <img
|
|
|
+ key={`${image.previewUrl}:${index}`}
|
|
|
+ className={css.thumb}
|
|
|
+ src={image.previewUrl}
|
|
|
+ alt={t('queue.image')}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ <span className={css.preview}>{projectUserText(submission.text, [])}</span>
|
|
|
</li>
|
|
|
))}
|
|
|
</ul>
|
|
|
@@ -210,7 +293,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
|
|
/** Registers queue actions backed by the session-scoped conversation service. */
|
|
|
export const queueDockEntry = {
|
|
|
name: 'conversation-queue-dock',
|
|
|
- inject: ['slots', 'conversation', 'sessions'],
|
|
|
+ inject: ['slots', 'conversation', 'sessions', 'uiConversation'],
|
|
|
apply(ctx: Context): void {
|
|
|
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
|
name: 'conversation.input.dock',
|
|
|
@@ -225,6 +308,7 @@ export const queueDockEntry = {
|
|
|
return {
|
|
|
updateQueue: (itemId, action) => conversation.updateQueue(itemId, action),
|
|
|
notify: (level, text) => { conversation.input.for(actx).notify(level, text) },
|
|
|
+ loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment),
|
|
|
}
|
|
|
},
|
|
|
}, QueueDock))
|