refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
/* Plan-review takeover: the waiting-approval card language (amber strip on a
|
||||
floating capsule, right-aligned actions) applied to a reviewed plan. Kept as
|
||||
its own module rather than shared with ui-conversation's ApprovalPanel: the
|
||||
two takeovers agree on tokens and geometry, not on content — this one's body
|
||||
is scrollable markdown, that one's is a headline plus a command line. Warn
|
||||
semantics ride the alias state tokens; no hardcoded colors. */
|
||||
|
||||
/* Mirrors the question card's frame so the takeover is a content swap. */
|
||||
.frame {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* Sides = clearance + 16px: the card lands on the shared content width
|
||||
(input card - 32) at every viewport. */
|
||||
padding: 6px calc(var(--dsh-composer-side-clearance) + 16px) 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: var(--dsh-chat-content-width);
|
||||
/* Composer seat sits in a fixed-height conversation column (overflow
|
||||
hidden): cap the card against the viewport and scroll the plan, so the
|
||||
strip and the decision row stay reachable on a long plan. */
|
||||
max-height: min(60vh, 520px);
|
||||
border: 1px solid var(--dsw-alias-state-warn-secondary);
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
/* Elevated surface in dark: the plan body inside scrolls once the card hits
|
||||
the cap above, so the thumb takes the l2 pair (see ui-theme
|
||||
styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.card,
|
||||
.card * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Tinted full-width header band, as on the approval takeover. */
|
||||
.strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
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);
|
||||
}
|
||||
|
||||
/* The plan is the panel's message: it takes the whole body and the scroll. */
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 12px 16px 4px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
padding: 8px 16px 12px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
min-height: 16px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* The discuss verb stays a quiet text button beside the two decision
|
||||
capsules: 14px glyph against the 14px label with a slightly wider gap, so
|
||||
the icon reads as a prefix rather than a peer-sized control. */
|
||||
.discuss {
|
||||
gap: 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.discuss:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 10px 12px 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
align-items: flex-end;
|
||||
padding: 8px 12px 10px;
|
||||
}
|
||||
}
|
||||
100
packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx
Normal file
100
packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
// PlanReviewPanel: the composer takeover for a question carrying the
|
||||
// `plan-review` presentation intent. A plan under review is one decision over
|
||||
// one body of markdown, so it takes the waiting-approval card shape — tinted
|
||||
// strip, content, right-aligned action row — instead of the generic question
|
||||
// flow's pager, numbered options, skip and custom-answer affordances, which
|
||||
// read as a quiz the user is being graded on.
|
||||
//
|
||||
// The three actions are the whole decision surface: approve and decline answer
|
||||
// the question with the option labels the asker offered (localised copy on the
|
||||
// buttons, the asker's descriptions as their tooltips), while "discuss"
|
||||
// dismisses the request so the composer returns and the user can simply say
|
||||
// what they want. Dismissal is the generic flow's own cancel verb, promoted to
|
||||
// a labelled button because in a two-outcome decision it is the third real
|
||||
// answer, not an escape hatch.
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts'
|
||||
import css from './PlanReviewPanel.module.css'
|
||||
|
||||
/** The panel's own props: the question domain face, the narrowed review, and the locale seat. */
|
||||
export type PlanReviewPanelProps =
|
||||
{ pending: PendingQuestion; review: PlanReview } & Pick<QuestionComposerProps, 't'>
|
||||
|
||||
/**
|
||||
* Optional-prop spread for a decision button's tooltip: `title` is optional on
|
||||
* the DOM props, and exactOptionalPropertyTypes rejects an explicit undefined.
|
||||
*
|
||||
* @param description - the asker's option description, when it carries one.
|
||||
* @returns The `title` prop to spread, or nothing.
|
||||
*/
|
||||
function tooltip(description: string | undefined): { title?: string } {
|
||||
return description === undefined ? {} : { title: description }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a plan review as a decision card.
|
||||
*
|
||||
* @param props - the question domain face, the narrowed plan review, and `t`.
|
||||
* @returns The plan-review takeover for this request.
|
||||
*/
|
||||
export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
|
||||
// One-shot latch shaped like the approval takeover's: the panel leaves only
|
||||
// when the host's resolved frame lands, so until then a second click must
|
||||
// not re-fire. A failed send (rejected receipt / transport) re-arms it and
|
||||
// shows why, since nothing else would tell the user the click was lost.
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const settle = (send: () => Promise<void>): void => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
void send().catch((cause: unknown) => {
|
||||
setBusy(false)
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
})
|
||||
}
|
||||
const decide = (label: string): void => {
|
||||
settle(() => pending.answer({ answers: [{ id: review.id, selected: [label] }] }))
|
||||
}
|
||||
const decline = review.decline
|
||||
|
||||
return (
|
||||
<div className={css.frame} data-plan-review-key={pending.key}>
|
||||
<section className={css.card} aria-label={review.question}>
|
||||
<div className={css.strip}>
|
||||
<span className={css.dot} />
|
||||
{t('plan.header')}
|
||||
</div>
|
||||
<div className={css.body} data-plan-review-scroll>
|
||||
<MarkdownText text={review.plan} />
|
||||
</div>
|
||||
<div className={css.footer}>
|
||||
<div className={css.feedback} role="status">{error}</div>
|
||||
<div className={css.actions}>
|
||||
<Button
|
||||
variant="ghost" className={css.discuss} icon={<IconEditOutline16 size={14} />}
|
||||
disabled={busy} onClick={() => { settle(() => pending.cancel()) }}
|
||||
>
|
||||
{t('plan.discuss')}
|
||||
</Button>
|
||||
{decline !== undefined && (
|
||||
<Button
|
||||
variant="outline" {...tooltip(decline.description)}
|
||||
disabled={busy} onClick={() => { decide(decline.label) }}
|
||||
>
|
||||
{t('plan.decline')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary" {...tooltip(review.approve.description)}
|
||||
disabled={busy} onClick={() => { decide(review.approve.label) }}
|
||||
>
|
||||
{t('plan.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/* The takeover seats where the input card sits, centered on the InputBar
|
||||
axis at the shared content width (input card - 32): sides = clearance +
|
||||
16px so the relation also holds on narrow viewports. */
|
||||
.frame {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px calc(var(--dsh-composer-side-clearance) + 16px) 10px;
|
||||
}
|
||||
|
||||
/* Figma Input 973:36348 body over the 1019:36938 header: no banner strip —
|
||||
the card keeps zero padding and sections carry their own insets. */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: var(--dsh-chat-content-width);
|
||||
/* Composer seat sits in a fixed-height conversation column (overflow
|
||||
hidden): cap the card against the viewport and scroll the option list
|
||||
so header and footer actions stay reachable on long batches. */
|
||||
max-height: min(60vh, 520px);
|
||||
padding: 0 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
overflow: hidden;
|
||||
/* Elevated surface in dark, same as the menus: the body inside scrolls
|
||||
once the card hits the cap above, so the thumb takes the l2 pair. Declared
|
||||
on the card because the elevation belongs to the surface, and the custom
|
||||
properties inherit down to `.body` (see ui-theme styles/scrollbar.css
|
||||
for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.card,
|
||||
.card * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Figma 1019:36938 header, user-tuned: heading block left, close right; the
|
||||
pager sits in the footer to balance the card. */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
padding: 20px 16px 0 24px;
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
/* Eyebrow-to-title gap widened from the figma 2px (user-tuned). */
|
||||
margin-bottom: 5px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 0 2px 8px;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
padding: 0 4px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
/* Narrow the plain spaces around the slash without touching glyph tracking. */
|
||||
word-spacing: -2px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.iconButton:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin: 8px 0 0;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
/* flex-start, not center: with a wrapped description the indicator must
|
||||
stay on the FIRST line (centering drifts it down the taller copy block).
|
||||
The 8px padding makes a single-line row 40px exactly, so nothing reads
|
||||
as top-heavy; .number/.checkbox re-center against the first line box. */
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
/* Rows are the scroll content, never the slack absorber: a shrinkable row
|
||||
collapses to min-height while its wrapped copy keeps the taller
|
||||
intrinsic height, and centered content then paints outside the row box —
|
||||
over the title and the next row. Overflow belongs to .options. */
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px 8px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.option:hover:not(:disabled),
|
||||
.optionSelected {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.optionSelected {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.option:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Leading indicator (figma 20×20, radius 6): single-select shows the option
|
||||
number, multi-select swaps in a checkbox; the custom-answer row follows —
|
||||
its checkbox mirrors the typed draft (styling only, exclusivity holds). */
|
||||
.number {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
/* (24px first-line box − 20px indicator) / 2: centers the indicator against
|
||||
the first text line under the row's flex-start alignment. */
|
||||
margin-top: 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-overlay);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
/* Multi-select box (figma 1055:41594, user-tuned down to 14×14): a radius-4
|
||||
box centered in the 20px indicator seat; the box itself is the ::before
|
||||
layer so the check icon stacks over it in the same grid cell. */
|
||||
.checkbox {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
/* Same first-line centering as .number under flex-start alignment. */
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.checkbox::before {
|
||||
content: '';
|
||||
grid-area: 1 / 1;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l4);
|
||||
border-radius: 4px;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.checkbox > svg {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
/* Checked: label-primary fill with a primary-foreground check — the pair
|
||||
inverts with the theme (dark fill in light mode, light fill in dark mode). */
|
||||
.checkboxChecked {
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
}
|
||||
|
||||
.checkboxChecked::before {
|
||||
border-color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.optionCopy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.optionLine {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 6px;
|
||||
}
|
||||
|
||||
.optionLabel {
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-specific-sidebar-nav-item-active-accent);
|
||||
color: var(--dsw-alias-button-info-fill);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Custom answer row (figma 973:36427): an option-shaped row whose copy is an
|
||||
inline text input; focus or a typed draft lifts it to the selected look. */
|
||||
.customRow {
|
||||
display: flex;
|
||||
/* Same first-line alignment as .option — the indicator seat carries the
|
||||
2px re-centering margin. */
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
/* Same reason as .option: the custom row is scroll content, and shrinking
|
||||
it pushes the inline input past the footer. */
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px 8px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.customRow:hover,
|
||||
.customRow:focus-within,
|
||||
.customRowActive {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.customRow:focus-within,
|
||||
.customRowActive {
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.customInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
caret-color: var(--dsw-alias-state-business-primary);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.customInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Optionless question: the free-form answer is the whole body. The 12px side
|
||||
margins add to the .options 12px padding so both edges align with the
|
||||
title's 24px inset; type matches the option rows, no resize handle. */
|
||||
.customTextarea {
|
||||
display: block;
|
||||
min-height: 64px;
|
||||
max-height: 140px;
|
||||
flex-shrink: 0;
|
||||
margin: 0 12px;
|
||||
padding: 8px 12px;
|
||||
resize: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
caret-color: var(--dsw-alias-state-business-primary);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.customTextarea:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.customTextarea::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 12px;
|
||||
padding: 0 10px 0 18px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
flex: 1;
|
||||
min-height: 16px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 10px 12px 0 18px;
|
||||
}
|
||||
|
||||
.options {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.option,
|
||||
.customRow {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
align-items: flex-end;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.option,
|
||||
.customRow {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
|
||||
IconCloseOutline16, IconEditOutline16, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
PendingQuestion, planReviewOf,
|
||||
type QuestionAnswer, type QuestionComposerProps,
|
||||
} from './contract/slots.ts'
|
||||
import { PlanReviewPanel } from './PlanReviewPanel.tsx'
|
||||
import css from './QuestionComposer.module.css'
|
||||
|
||||
interface DraftAnswer {
|
||||
selected: string[]
|
||||
custom: string
|
||||
skipped: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Displayed feedback: validation feedback is stored as a dictionary KEY and
|
||||
* translated at render, so already-shown feedback follows a locale switch;
|
||||
* runtime failure messages (finished strings from the wire) pass through
|
||||
* verbatim.
|
||||
*/
|
||||
type Feedback = { key: 'error.incomplete' | 'error.unanswered' } | { text: string }
|
||||
|
||||
/**
|
||||
* Split the conventional recommendation suffix without changing the answer value.
|
||||
* @param label - Original option label returned if selected.
|
||||
* @returns Display label plus recommendation state.
|
||||
*/
|
||||
export function parseRecommendedLabel(label: string): { label: string; recommended: boolean } {
|
||||
const suffix = /\s*(?:\((?:recommended|推荐)\)|((?:recommended|推荐)))\s*$/i
|
||||
return suffix.test(label)
|
||||
? { label: label.replace(suffix, ''), recommended: true }
|
||||
: { label, recommended: false }
|
||||
}
|
||||
|
||||
/** Return whether a text-field key event belongs to an active IME composition. */
|
||||
function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>): boolean {
|
||||
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer takeover boundary; the carrier key keys local drafts, so a
|
||||
* same-request replay (same key, new carrier object) preserves them.
|
||||
*
|
||||
* One takeover, two shapes: a request that declares a presentation intent this
|
||||
* package renders takes that shape (a plan review is one decision over one
|
||||
* plan, not a question set), and every other request takes the generic flow.
|
||||
* The routing lives here, at the one entry that owns the composer seat, so
|
||||
* neither shape can claim a request the other is already rendering.
|
||||
*
|
||||
* @param props - the selector-matched pending question carrier plus the framework standard kit.
|
||||
* @returns The question flow, or the intent's own surface, for this request.
|
||||
*/
|
||||
export function QuestionComposer(props: QuestionComposerProps) {
|
||||
// Domain-face mint rides the carrier's stable identity (never minted in a
|
||||
// select/render dispatch — per-dispatch minting would churn memo identity).
|
||||
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
|
||||
const review = useMemo(() => planReviewOf(question.questions), [question])
|
||||
return review === undefined
|
||||
? <QuestionFlow key={question.key} pending={question} t={props.t} />
|
||||
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
|
||||
}
|
||||
|
||||
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
|
||||
const questions = pending.questions
|
||||
const [index, setIndex] = useState(0)
|
||||
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(() => ({
|
||||
selected: [], custom: '', skipped: false,
|
||||
})))
|
||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||
const [error, setError] = useState<Feedback | null>(null)
|
||||
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const question = questions[index]!
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const draft = drafts[index]!
|
||||
const hasOptions = (question.options?.length ?? 0) > 0
|
||||
|
||||
const cancelFlow = (): void => {
|
||||
setBusy('cancel')
|
||||
setError(null)
|
||||
void pending.cancel().catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
}
|
||||
|
||||
const updateDraft = (update: (current: DraftAnswer) => DraftAnswer): void => {
|
||||
setDrafts(current => current.map((item, itemIndex) => itemIndex === index ? update(item) : item))
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const choose = (label: string): void => {
|
||||
updateDraft((current) => {
|
||||
if (question.multiSelect === true) {
|
||||
const selected = current.selected.includes(label)
|
||||
? current.selected.filter(item => item !== label)
|
||||
: [...current.selected, label]
|
||||
return { ...current, selected, skipped: false }
|
||||
}
|
||||
return { selected: [label], custom: '', skipped: false }
|
||||
})
|
||||
if (question.multiSelect !== true && index < questions.length - 1) {
|
||||
setIndex(current => current + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const answered = (item: DraftAnswer): boolean =>
|
||||
item.selected.length > 0 || item.custom.trim() !== ''
|
||||
|
||||
const completed = (item: DraftAnswer): boolean => answered(item) || item.skipped
|
||||
|
||||
const submitDrafts = (values: DraftAnswer[]): void => {
|
||||
const missing = values.findIndex(item => !completed(item))
|
||||
if (missing >= 0) {
|
||||
setIndex(missing)
|
||||
setError({ key: 'error.incomplete' })
|
||||
return
|
||||
}
|
||||
const answer: QuestionAnswer = {
|
||||
answers: questions.map((item, itemIndex) => {
|
||||
const value = values[itemIndex] as DraftAnswer
|
||||
if (value.skipped) return { id: item.id, selected: [] }
|
||||
const custom = value.custom.trim()
|
||||
return {
|
||||
id: item.id,
|
||||
selected: custom === '' || item.multiSelect === true ? value.selected : [],
|
||||
...(custom === '' ? {} : { custom }),
|
||||
}
|
||||
}),
|
||||
}
|
||||
setBusy('answer')
|
||||
setError(null)
|
||||
void pending.answer(answer).catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
}
|
||||
|
||||
const continueFlow = (): void => {
|
||||
if (!answered(draft)) {
|
||||
setError({ key: 'error.unanswered' })
|
||||
return
|
||||
}
|
||||
if (index < questions.length - 1) {
|
||||
setIndex(current => current + 1)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
submitDrafts(drafts)
|
||||
}
|
||||
|
||||
// Shared by the inline custom input and the optionless textarea: a
|
||||
// multi-select draft retains checked labels, while a single-select custom
|
||||
// answer replaces its selection. Enter continues the flow (Shift+Enter
|
||||
// stays a newline in the textarea; on the single-line input it is inert).
|
||||
const draftCustom = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
const value = event.target.value
|
||||
updateDraft(current => ({
|
||||
...current,
|
||||
selected: question.multiSelect === true ? current.selected : [],
|
||||
custom: value,
|
||||
skipped: false,
|
||||
}))
|
||||
}
|
||||
|
||||
const continueFromCustom = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
if (event.key !== 'Enter' || event.shiftKey || isComposing(event)) return
|
||||
event.preventDefault()
|
||||
continueFlow()
|
||||
}
|
||||
|
||||
const skipQuestion = (): void => {
|
||||
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
|
||||
? { selected: [], custom: '', skipped: true }
|
||||
: item)
|
||||
setDrafts(nextDrafts)
|
||||
setError(null)
|
||||
if (index < questions.length - 1) {
|
||||
setIndex(current => current + 1)
|
||||
return
|
||||
}
|
||||
submitDrafts(nextDrafts)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.frame} data-question-key={pending.key}>
|
||||
<section className={css.card} aria-labelledby={`question-${pending.key}-${String(index)}`}>
|
||||
<header className={css.header}>
|
||||
<div className={css.headingBlock}>
|
||||
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
|
||||
<h2 className={css.title} id={`question-${pending.key}-${String(index)}`}>
|
||||
{question.question}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
|
||||
title={t('nav.cancel')}
|
||||
disabled={busy !== null} onClick={cancelFlow}
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className={css.body} data-question-scroll>
|
||||
{question.detail !== undefined && (
|
||||
<div className={css.detail}><MarkdownText text={question.detail} /></div>
|
||||
)}
|
||||
<div className={css.options} role={question.multiSelect === true ? 'group' : 'radiogroup'}>
|
||||
{(question.options ?? []).map((option, optionIndex) => {
|
||||
const selected = draft.selected.includes(option.label)
|
||||
const display = parseRecommendedLabel(option.label)
|
||||
return (
|
||||
<button
|
||||
type="button" key={`${option.label}-${String(optionIndex)}`}
|
||||
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
|
||||
role={question.multiSelect === true ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-label={display.label}
|
||||
disabled={busy !== null}
|
||||
onClick={() => { choose(option.label) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' || !drafts.every(completed)) return
|
||||
event.preventDefault()
|
||||
submitDrafts(drafts)
|
||||
}}
|
||||
>
|
||||
{question.multiSelect === true
|
||||
? (
|
||||
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
|
||||
{selected && <IconCheckOutline14 size={12} />}
|
||||
</span>
|
||||
)
|
||||
: <span className={css.number}>{optionIndex + 1}</span>}
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.optionLine}>
|
||||
<span className={css.optionLabel}>{display.label}</span>
|
||||
{display.recommended && (
|
||||
<span className={css.badge}>{t('option.recommended')}</span>
|
||||
)}
|
||||
{option.description !== undefined && (
|
||||
<span className={css.description}>{option.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{hasOptions
|
||||
? (
|
||||
<div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
|
||||
{question.multiSelect === true
|
||||
? (
|
||||
<span
|
||||
className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{draft.custom !== '' && <IconCheckOutline14 size={12} />}
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className={css.number} aria-hidden="true">
|
||||
<IconEditOutline16 size={12} />
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
className={css.customInput}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={draftCustom}
|
||||
onKeyDown={continueFromCustom}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<textarea
|
||||
autoFocus
|
||||
className={css.customTextarea}
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
rows={2}
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={draftCustom}
|
||||
onKeyDown={continueFromCustom}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className={css.footer}>
|
||||
<div className={css.pager}>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.prev')}
|
||||
disabled={index === 0 || busy !== null}
|
||||
onClick={() => { setIndex(index - 1); setError(null) }}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
<span className={css.progress}>{index + 1} / {questions.length}</span>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label={t('nav.next')}
|
||||
disabled={index === questions.length - 1 || busy !== null}
|
||||
onClick={() => { setIndex(index + 1); setError(null) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.feedback} role="status">
|
||||
{error === null ? null : 'key' in error ? t(error.key) : error.text}
|
||||
</div>
|
||||
<div className={css.footerActions}>
|
||||
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
|
||||
{t('action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
|
||||
>
|
||||
{busy === 'answer'
|
||||
? t('submitting')
|
||||
: index === questions.length - 1 ? t('submit') : t('action.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
143
packages/client/ui-user-questions/src/client/contract/slots.ts
Normal file
143
packages/client/ui-user-questions/src/client/contract/slots.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Question-composer slot contract: the registrant-side props composition for
|
||||
* the conversation-owned `conversation.composer` slot, plus the question
|
||||
* domain face over the runtime's carrier object. The carrier (PendingWait)
|
||||
* owns envelope transport only; the question protocol — answer value shape,
|
||||
* cancelled error encoding, receipt checks — lives HERE, with the package
|
||||
* that consumes it.
|
||||
*/
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
|
||||
// entry) into every program that sees this contract, so PropsRuntime resolves.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { QuestionResponsePayload } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
|
||||
/** The pending question carrier the owner dispatches into the composer slot. */
|
||||
export type QuestionWait = PendingWait<'question'>
|
||||
|
||||
/** One structured answer batch covering every question of the request. */
|
||||
export type QuestionAnswer = QuestionResponsePayload['answer']
|
||||
|
||||
/** One question of the request, as the carrier payload carries it. */
|
||||
type QuestionItem = QuestionWait['payload']['questions'][number]
|
||||
|
||||
/** One option the asker offered on a question. */
|
||||
type QuestionOption = NonNullable<QuestionItem['options']>[number]
|
||||
|
||||
/**
|
||||
* A request narrowed to the `plan-review` presentation intent: everything the
|
||||
* decision card renders and answers with, so the panel never re-reads the
|
||||
* request shape. `approve` and `decline` are the asker's own options — an
|
||||
* answer must carry one of those labels verbatim — and `plan` is the markdown
|
||||
* body under review.
|
||||
*/
|
||||
export interface PlanReview {
|
||||
/** The reviewed question's id, echoed in the answer. */
|
||||
id: string
|
||||
/** The question text, kept as the card's accessible name. */
|
||||
question: string
|
||||
/** The plan markdown under review. */
|
||||
plan: string
|
||||
/** The option that approves the plan. */
|
||||
approve: QuestionOption
|
||||
/** The option that declines it; absent when the asker offered no other option. */
|
||||
decline?: QuestionOption
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a request to a renderable plan review, or return undefined to leave it
|
||||
* to the generic question flow.
|
||||
*
|
||||
* The card is one decision over one plan, and it claims a request only when it
|
||||
* can send every answer that request allows — an intent changes the layout,
|
||||
* never which answers are reachable. So the batch must be a single question
|
||||
* that declares the intent, carries the plan as its detail, offers the approve
|
||||
* label the intent names, and is a binary single choice: at most one option
|
||||
* besides approve, and not multi-select. A third option or a multi-select batch
|
||||
* has answers two buttons cannot express, so the generic flow keeps it — as it
|
||||
* keeps any request whose intent the asker's own service would have rejected,
|
||||
* because the client sits downstream of a wire boundary and every request must
|
||||
* stay answerable.
|
||||
*
|
||||
* @param questions - the request's whole question batch.
|
||||
* @returns The narrowed review, or undefined when the generic flow owns it.
|
||||
*/
|
||||
export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | undefined {
|
||||
if (questions.length !== 1) return undefined
|
||||
// Length-checked above; the index read is the narrowing tax, not a guess.
|
||||
const question = questions[0] as QuestionItem
|
||||
const intent = question.intent
|
||||
if (intent?.kind !== 'plan-review' || question.detail === undefined) return undefined
|
||||
if (question.multiSelect === true) return undefined
|
||||
const options = question.options ?? []
|
||||
if (options.length > 2) return undefined
|
||||
const approve = options.find(option => option.label === intent.approve)
|
||||
if (approve === undefined) return undefined
|
||||
const decline = options.find(option => option.label !== intent.approve)
|
||||
return {
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
plan: question.detail,
|
||||
approve,
|
||||
...(decline === undefined ? {} : { decline }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Question domain face over the carrier: render identity and questions
|
||||
* transparently forwarded; answer/cancel own the wire encoding (the success
|
||||
* fields and the cancelled error) and turn a rejected carrier receipt into a
|
||||
* thrown error. Components mint one per carrier via useMemo (never inside a
|
||||
* select — a per-dispatch mint would churn identity and break memoization).
|
||||
*/
|
||||
export class PendingQuestion {
|
||||
/**
|
||||
* @param wait - the runtime carrier for one pending question request.
|
||||
*/
|
||||
constructor(private readonly wait: QuestionWait) {}
|
||||
|
||||
/** Opaque render identity (React key / draft remount axis), forwarded from the carrier. */
|
||||
get key(): string {
|
||||
return this.wait.key
|
||||
}
|
||||
|
||||
/** The request's question list, forwarded from the carrier payload. */
|
||||
get questions(): QuestionWait['payload']['questions'] {
|
||||
return this.wait.payload.questions
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the whole answer batch; a rejected carrier receipt throws.
|
||||
* @param answer - complete structured answer batch.
|
||||
*/
|
||||
async answer(answer: QuestionAnswer): Promise<void> {
|
||||
const receipt = await this.wait.respond({
|
||||
ok: true, value: { sessionId: this.wait.sessionId, answer },
|
||||
})
|
||||
if (!receipt.accepted) {
|
||||
throw new Error(`question response rejected: ${receipt.reason}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject the whole wait (the host resolves the tool call as cancelled); a rejected receipt throws. */
|
||||
async cancel(): Promise<void> {
|
||||
const receipt = await this.wait.respond({
|
||||
ok: false,
|
||||
error: { code: 'cancelled', message: 'the user closed this question request', details: {} },
|
||||
})
|
||||
if (!receipt.accepted) {
|
||||
throw new Error(`question cancellation rejected: ${receipt.reason}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full component 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 question carrier — plus the
|
||||
* standard locale seat; the carrier plus the domain face above carry the
|
||||
* whole behavior surface.
|
||||
*/
|
||||
export type QuestionComposerProps =
|
||||
PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>
|
||||
60
packages/client/ui-user-questions/src/client/index.ts
Normal file
60
packages/client/ui-user-questions/src/client/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Web question plugin, browser half: QuestionComposer registered as a
|
||||
* selector-routed entry of the conversation-declared composer chain, plus the
|
||||
* `question` dictionaries. The selector narrows the owner's currency to the
|
||||
* question carrier (matched prop), and the whole behavior surface rides the
|
||||
* carrier (domain encoding in contract/slots.ts PendingQuestion); copy rides
|
||||
* the standard locale seat. Export discipline: packages/client/AGENTS.md.
|
||||
*
|
||||
* One entry, two shapes: the composer renders a request that declares a
|
||||
* presentation intent as that intent's own surface (`plan-review` → the plan
|
||||
* decision card) and every other request as the generic question flow. A
|
||||
* separate chain entry per shape would race the same carrier, so the shape
|
||||
* choice lives inside this entry — see QuestionComposer.
|
||||
*/
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { QuestionWait } from './contract/slots.ts'
|
||||
import { QuestionComposer } from './QuestionComposer.tsx'
|
||||
import { en, zh, type QuestionKey } from './locales.ts'
|
||||
|
||||
export { PendingQuestion } from './contract/slots.ts'
|
||||
export type {
|
||||
PlanReview, QuestionAnswer, QuestionComposerProps, QuestionWait,
|
||||
} from './contract/slots.ts'
|
||||
export type { QuestionKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The question composer's copy. */
|
||||
question: QuestionKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'question'
|
||||
|
||||
/** Required services: the slot registry and the question composer's copy. */
|
||||
export const inject = ['slots', 'locale']
|
||||
|
||||
/** Chain routing: claim the composer while a question wait is pending (pure — owner props only). */
|
||||
function selectQuestion({ interactions }: ComposerChainProps): QuestionWait | null {
|
||||
return interactions.find((i): i is QuestionWait => i.kind === 'question') ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body: register the `question` dictionaries and the question
|
||||
* composer into the composer chain. Zero business face — data and verbs live
|
||||
* on the matched carrier; t rides the standard locale seat.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-user-questions: dictionaries')
|
||||
|
||||
ctx.slots.inject('conversation.composer', () => ctx.slots.register(
|
||||
{ name: 'conversation.composer', select: selectQuestion, locale: NS },
|
||||
QuestionComposer,
|
||||
))
|
||||
}
|
||||
38
packages/client/ui-user-questions/src/client/locales.ts
Normal file
38
packages/client/ui-user-questions/src/client/locales.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** `question` namespace dictionaries. */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'error.incomplete': '请先完成这道问题。',
|
||||
'error.unanswered': '请选择一个选项或填写自定义答案。',
|
||||
'nav.prev': '上一题',
|
||||
'nav.next': '下一题',
|
||||
'nav.cancel': '放弃整组问题',
|
||||
'option.recommended': '推荐',
|
||||
'custom.placeholder': '输入你的答案',
|
||||
'action.skip': '跳过本题',
|
||||
'action.next': '下一题',
|
||||
'plan.header': '计划待审',
|
||||
'plan.approve': '确认执行',
|
||||
'plan.decline': '拒绝',
|
||||
'plan.discuss': '去聊天里说',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The question namespace key union. */
|
||||
export type QuestionKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'error.incomplete': 'Please complete this question first.',
|
||||
'error.unanswered': 'Please select an option or enter a custom answer.',
|
||||
'nav.prev': 'Previous question',
|
||||
'nav.next': 'Next question',
|
||||
'nav.cancel': 'Dismiss all questions',
|
||||
'option.recommended': 'Recommended',
|
||||
'custom.placeholder': 'Type your answer',
|
||||
'action.skip': 'Skip this question',
|
||||
'action.next': 'Next',
|
||||
'plan.header': 'Plan review',
|
||||
'plan.approve': 'Approve',
|
||||
'plan.decline': 'Refuse',
|
||||
'plan.discuss': 'Chat about it',
|
||||
} satisfies Record<QuestionKey, string>
|
||||
4
packages/client/ui-user-questions/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-user-questions/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Readonly<Record<string, string>>
|
||||
export default classes
|
||||
}
|
||||
14
packages/client/ui-user-questions/src/index.ts
Normal file
14
packages/client/ui-user-questions/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Web question plugin, node half.
|
||||
*
|
||||
* Deliberately empty. Mounting `ask_user_question` here put it in the tools
|
||||
* registry's GLOBAL layer, so every agent saw it no matter which preset
|
||||
* composed it — a two-tool benchmark preset actually presented three, and a
|
||||
* locally authored `bash-only` preset presented two. Rendering a question is
|
||||
* a host UI capability; having the tool is an agent capability, and only a
|
||||
* preset decides that. The `tool-ask-user` row belongs in the presets that
|
||||
* want it (and in the TUI composition, which has no presets).
|
||||
*/
|
||||
|
||||
/** Host plugin body — the model-facing tool is composed per preset, not here. */
|
||||
export function apply(): void {}
|
||||
31
packages/client/ui-user-questions/src/invariant.ts
Normal file
31
packages/client/ui-user-questions/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-user-questions`.
|
||||
* @module @deepseek-ai/dsh-client-ui-user-questions/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-user-questions'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-user-questions-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: tool and slot registrations are effects
|
||||
* owned and observed by their respective registries; the host pending table is
|
||||
* exercised through the public wire protocol.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns The installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user