Merge remote-tracking branch 'origin/master' into codex/migrate-to-oxlint

This commit is contained in:
Turtle
2026-07-29 14:32:21 +08:00
308 changed files with 10890 additions and 1735 deletions
@@ -5,7 +5,8 @@ import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/clien
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ViewTab } from './contract/views.ts'
import type {
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
@@ -15,6 +16,7 @@ import { InputHub } from './input/hub.ts'
import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
@@ -34,6 +36,11 @@ function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
return conversation
}
/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null {
return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null
}
/** Mounts the conversation plugin.
* @param ctx - Client root context.
*/
@@ -146,11 +153,27 @@ export function apply(ctx: Context): void {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
command: async (line) => {
const session = sessions.binding(sessionId)?.session
if (session === undefined) return false
const result = await session.command(line)
return result.ok && result.value.matched
},
hooks: { notices: shell.notices, lexicon: shell.lexicon },
}
},
}, InputBar)
// The approval takeover: a selector-routed entry of the chain this package
// just declared (the ui-question registration pattern; the entry lives here
// because approval answering is core conversation UX, not an optional tool).
// Zero business face — data and verbs both ride the matched carrier.
// priority 1: question takeovers (default 0) win when both kinds are
// pending — a question is a conversation the model is waiting on, while an
// approval only blocks one tool call; answering the question first cannot
// strand the approval (it re-elects the moment the question resolves).
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1 }, ApprovalPanel)
// The chat view: first entry of the ring this package just declared.
// Declaring the keyed toolview hole here is claiming it: ChatView is the
// only component authorized to render per-tool rows. Shares the chat
@@ -30,7 +30,6 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem } from './MessageItem.tsx'
import { PendingCard } from './PendingCard.tsx'
import { StatsLine } from './StatsLine.tsx'
import css from './ChatView.module.css'
@@ -229,7 +228,6 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const running = useSession(s => s.running)
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const pending = useSession(s => s.pending)
const openState = useSession(s => s.openState)
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}${s.openError.code}`)
const hasMore = useSession(s => s.hasMore)
@@ -375,9 +373,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
))}
</div>
)}
{pending.map(item => item.kind === 'approval'
? <PendingCard key={item.key} item={item} />
: null)}
{/* No pending placeholders: questions (ui-question) and approvals
(ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnDots />}
@@ -1,31 +0,0 @@
/* Amber pending strip (approval waiting = warn semantic, figma state colors). */
.card {
margin: 6px 0;
padding: 8px 12px;
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: 10px;
background: var(--dsw-alias-state-warn-tertiary);
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.mono {
font-family: var(--ds-font-family-code);
}
.reason {
margin-top: 4px;
font-size: 12px;
color: var(--dsw-alias-label-secondary);
}
.hint {
margin-top: 6px;
font-size: 11px;
color: var(--dsw-alias-label-tertiary);
}
@@ -1,20 +0,0 @@
// PendingCard: display-only approval placeholder. Questions render exclusively
// through the composer takeover so the same pending wait is never shown twice.
import { memo } from 'react'
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import css from './PendingCard.module.css'
export interface PendingCardProps {
item: PendingWait<'approval'>
}
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
return (
<div className={css.card}>
<div className={css.title}><span className={css.mono}>{item.payload.toolName}</span></div>
{item.payload.reason !== undefined && <div className={css.reason}>{item.payload.reason}</div>}
<div className={css.hint}>web </div>
</div>
)
})
@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
@@ -251,6 +251,12 @@ export interface ComposerBarInjected {
keyboard: ComposerKeyboard
/** Cancel the in-flight turn. */
stop: () => void
/**
* Submit one slash-command line against this session's agent (the chrome
* controls' write path — the permission chip submits `/permission <preset>`).
* Resolves admission: false = rejected/unmatched/transport failure.
*/
command: (line: string) => Promise<boolean>
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
@@ -307,6 +313,68 @@ export type ConversationSessionSlotProps =
& PropsStore<ChatStore>
& ConversationSessionInjected
/** The pending approval carrier the owner dispatches into the composer chain. */
export type ApprovalWait = PendingWait<'approval'>
/**
* Approval domain face over the carrier (the ui-question PendingQuestion
* pattern): render identity and question material forwarded transparently;
* answer owns the wire encoding — the ApprovalResponsePayload value shape
* with the audit correlation the host reconciles — and turns a rejected
* carrier receipt into a thrown error. Minted per carrier via useMemo.
*/
export class PendingApproval {
/**
* @param wait - the runtime carrier for one pending approval question.
*/
constructor(private readonly wait: ApprovalWait) {}
/** Opaque render identity (React key / one-shot latch remount axis), forwarded from the carrier. */
get key(): string {
return this.wait.key
}
/** The tool the question is about (headline fallback), forwarded from the carrier payload. */
get toolName(): string {
return this.wait.payload.toolName
}
/** The asker's human-readable WHY (headline when present), forwarded from the carrier payload. */
get reason(): string | undefined {
return this.wait.payload.reason
}
/** The paired tool call's id when the ask names one (command-line lookup key), forwarded from the carrier payload. */
get callId(): string | undefined {
return this.wait.payload.callId
}
/**
* Deliver the user's decision; a rejected carrier receipt throws. Panel
* removal stays frame-driven: the broadcast `approval/resolved` settles the
* wait and drops it from the pending list.
* @param outcome - the only two client-answerable outcomes.
*/
async answer(outcome: 'allowed-once' | 'rejected'): Promise<void> {
const receipt = await this.wait.respond({
ok: true,
value: { sessionId: this.wait.sessionId, approvalId: this.wait.payload.approvalId, outcome },
})
if (!receipt.accepted) {
throw new Error(`approval response rejected: ${receipt.reason}`)
}
}
}
/**
* Full approval-composer props: the framework runtime share (chain currency +
* session/global standard kit) plus the chain `matched` share — the entry's
* selector result, already narrowed to the approval carrier. No injected
* share: the carrier plus the domain face above carry the whole behavior
* surface; the paired command line derives from useSession in-component.
*/
export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> & { matched: ApprovalWait }
/**
* Injected share of the chat view entry: the two callbacks whose targets live
* outside the view (layout orchestration; the session object layer).
@@ -0,0 +1,107 @@
/* Composer-takeover approval panel (draft approval.png): the same floating
capsule footprint as the InputBar card, with an amber header band, the
justification headline, a muted command line, and right-aligned actions.
Warn semantics ride the alias state tokens; no hardcoded colors. */
/* Mirrors InputBar .root so the takeover is a content swap, not a layout jump. */
.root {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 32px 12px;
}
.card {
overflow: hidden;
width: 100%;
max-width: 776px;
border: 1px solid var(--dsw-alias-state-warn-secondary);
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
}
/* Tinted full-width header band. */
.strip {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-primary);
font-size: 13px;
line-height: 18px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dsw-alias-state-warn-primary);
}
.body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 16px 14px;
}
/* The model's justification is the panel's message, not a footnote. */
.headline {
color: var(--dsw-alias-label-primary);
font-size: 15px;
font-weight: 500;
line-height: 24px;
}
.command {
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 13px;
line-height: 20px;
word-break: break-all;
}
.actionRow {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
.allow,
.reject {
padding: 6px 16px;
border-radius: 10px;
font-size: 13px;
line-height: 18px;
cursor: pointer;
}
.allow:disabled,
.reject:disabled {
opacity: 0.5;
cursor: default;
}
/* Primary action: filled ink (draft's rightmost emphasis, minus the dropped
always-allow button). */
.allow {
border: none;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-label-primary-foreground);
}
/* Secondary: quiet outline. */
.reject {
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
background: transparent;
color: var(--dsw-alias-label-secondary);
}
.reject:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
border-color: transparent;
}
@@ -0,0 +1,71 @@
// ApprovalPanel: the composer-takeover approval prompt (designer draft
// approval.png), registered as a selector-routed entry of the
// conversation-declared composer chain. While an approval question is
// pending, this panel occupies the composer slot in place of the InputBar:
// an amber "Waiting for approval" strip on the card top, the model's
// justification as the headline, the paired command in muted code text, and
// a right-aligned refuse/allow action row. One-shot: the buttons disable
// after a click and the panel leaves (the InputBar returns) on the broadcast
// resolved frame. The draft's "Always allow this type" is deferred with
// grant storage.
import { useMemo, useState } from 'react'
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
import css from './ApprovalPanel.module.css'
/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */
export function commandOf(call: RunningToolCall | undefined): string | undefined {
if (call === undefined) return undefined
try {
const args = JSON.parse(call.argsRaw) as Record<string, unknown>
return typeof args.command === 'string' ? args.command : undefined
} catch {
// Unparseable model args: the panel still renders, just without the command line.
return undefined
}
}
/**
* Composer takeover boundary: mints the domain face on the carrier's stable
* identity and remounts the flow per request key, so the one-shot answered
* latch never leaks to the next pending approval.
* @param props - the selector-matched pending approval carrier plus the framework standard kit.
* @returns The approval prompt for this request.
*/
export function ApprovalPanel(props: ApprovalComposerProps) {
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
const command = props.useSession(s => commandOf(
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
return <ApprovalFlow key={approval.key} pending={approval} {...command === undefined ? {} : { command }} />
}
function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?: string }) {
// Local one-shot latch: the panel leaves only when the resolved frame
// lands; until then the buttons must not re-fire. An answer failure
// (rejected receipt / transport) re-arms them for retry.
const [answered, setAnswered] = useState(false)
const answer = (outcome: 'allowed-once' | 'rejected'): void => {
setAnswered(true)
void pending.answer(outcome).catch(() => { setAnswered(false) })
}
return (
<div className={css.root} data-approval-key={pending.key}>
<div className={css.card}>
<div className={css.strip}><span className={css.dot} /></div>
<div className={css.body}>
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
{command !== undefined && <div className={css.command}>{command}</div>}
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
</button>
</div>
</div>
</div>
</div>
)
}
@@ -6,7 +6,7 @@
* region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -15,6 +15,7 @@ import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-plan-mode/client'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
/** Prompt failure surface (derived from promptError). */
@@ -25,13 +26,8 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
{ id: 'readonly', label: 'Read-only' },
{ id: 'readwrite', label: 'Read-write' },
]
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon, useProjection,
useSession, useInput, inputActions, keyboard, stop, command, renderSlot, useNotices, useLexicon, useProjection,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
@@ -64,9 +60,9 @@ export function InputBar({
}, 10)
}
// Placeholder chrome: Access selection stays local until its seam lands
// (plan/model are real seats now — the named single slots below).
const [readonlyId, setReadonlyId] = useState('readonly')
// The Access seat's data: the host-computed permissions projection
// (undefined = capability absent → the chip renders nothing).
const permissions = useProjection('permissions')
// Queue cut 1: running input stays free; locked = session disabled only.
// The transient machine locks (adjudicating pending / submitting) render
@@ -229,19 +225,10 @@ export function InputBar({
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
}
// Access placeholder select (the one remaining local-chrome control).
// The Access seat: the projection-fed permission chip (renders nothing
// while the permissions key is absent — permission-less host or Draft).
const accessSelect: ReactNode = (
<select
className={css.select}
aria-label="Access mode"
value={readonlyId}
disabled={locked}
onChange={(e: ChangeEvent<HTMLSelectElement>) => { setReadonlyId(e.target.value) }}
>
{READONLY_OPTIONS.map(opt => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
<PermissionSelect value={permissions} locked={locked} command={command} />
)
// Mirror-layer decorations: a visible backdrop with transparent text. The
@@ -0,0 +1,49 @@
/* Composer bottom-row permission chip (draft start.jpeg `Read-only `): a
quiet text chip with a chevron; hover paints the standard interactive pill.
The native select is stretched invisibly over the chip so the platform
dropdown does the menu work — keyboard/AT semantics come free. */
.root {
position: relative;
display: inline-flex;
align-items: center;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-radius: 8px;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 20px;
pointer-events: none; /* the overlaid select owns the interaction */
}
.root:hover .chip {
background: var(--dsw-alias-interactive-bg-hover);
}
.chevron {
color: var(--dsw-alias-label-caption);
}
/* Invisible native select stretched over the chip: real menu, zero drawing. */
.select {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
border: none;
cursor: pointer;
}
.select:disabled {
cursor: default;
}
.root:has(.select:disabled) .chip {
opacity: 0.5;
}
@@ -0,0 +1,80 @@
// PermissionSelect: the composer bottom-row permission chip (draft
// start.jpeg's `Read-only ` control), the Access seat's wired occupant.
// Options and the current value read from the host-computed `permissions`
// projection (baseline block + push frames — no fetch, no mount timing);
// key absence (a permission-less composition, or a Draft with no host
// session yet) renders nothing. The visible chip is presentation only — an
// invisible native select stretched over it owns the menu and interaction.
// A switch submits the `/permission <preset>` command line (the one write
// path); the control shows the picked value optimistically and disables
// until the admission result, then re-follows the projection — the pushed
// frame confirms the switch, and a failed/unmatched submit falls back to
// the still-authoritative projection value (`custom` is shown as the
// current value but never offered as a target — the host omits it from
// switchable options).
import { useState } from 'react'
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
import css from './PermissionSelect.module.css'
/**
* Display transform: kebab-case machine names render as title-case labels
* (`workspace-write` → `Workspace Write`). Presentation-only — the wire
* vocabulary and the host's advertised names are untouched; a host-configured
* name that is not kebab-case (contains spaces or uppercase) passes through.
*/
function displayName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
export interface PermissionSelectProps {
/** The host-computed select, or undefined while the capability is absent. */
value: PermissionSelectValue | undefined
/** Session-removed lock (the bar's chrome disable state). */
locked: boolean
/** Submit one slash-command line; resolves admission (false = rejected/unmatched). */
command: (line: string) => Promise<boolean>
}
export function PermissionSelect({ value, locked, command }: PermissionSelectProps) {
// Optimistic pick, shown while the admission round-trip runs; null follows
// the projection (the pushed frame lands the confirmed value there).
const [pick, setPick] = useState<string | null>(null)
if (value === undefined) return null
const currentValue = pick ?? value.currentValue
const current = value.options.find(option => option.value === currentValue)
const onChange = (next: string): void => {
if (next === value.currentValue) return
setPick(next)
void command(`/permission ${next}`)
.catch(() => false)
.then(() => { setPick(null) })
}
return (
<label className={css.root} title={current?.description}>
<span className={css.chip}>
{displayName(current?.name ?? currentValue)}
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</span>
<select
className={css.select}
aria-label="Access mode"
value={currentValue}
disabled={locked || pick !== null}
onChange={(e) => { onChange(e.target.value) }}
>
{value.options.map(option => (
<option key={option.value} value={option.value} disabled={option.value === 'custom'}>
{displayName(option.name)}
</option>
))}
</select>
</label>
)
}