@morlay/ui-conversation-message-actions 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ .input {
2
+ /* composer-card 同款视觉(ui-conversation InputBar.module.css 的 .card):
3
+ 圆角胶囊 + 输入 surface token + 阴影,编辑弹窗与主输入框观感一致。 */
4
+ width: 100%;
5
+ min-height: 96px;
6
+ box-sizing: border-box;
7
+ font: inherit;
8
+ font-size: 16px;
9
+ line-height: 24px;
10
+ padding: 10px 16px;
11
+ border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
12
+ border-radius: 22px;
13
+ background: var(--dsw-specific-input-major);
14
+ box-shadow: var(--dsw-shadow-lv2);
15
+ color: var(--dsw-text, inherit);
16
+ resize: none;
17
+ overflow-y: auto;
18
+ }
19
+
20
+ .actions {
21
+ display: flex;
22
+ justify-content: flex-end;
23
+ gap: 8px;
24
+ }
@@ -0,0 +1,71 @@
1
+ import { useRef, useState } from "react";
2
+ import { Button, Modal } from "@deepseek-ai/dsh-client-ui-primitives";
3
+ import type { EditableMessageBlock } from "../../shared.ts";
4
+ import css from "./MessageEditDialog.module.css";
5
+
6
+ const BLOCK_TITLE: Record<EditableMessageBlock["kind"], string> = {
7
+ user: "编辑用户消息",
8
+ "assistant.reasoning": "编辑助手思考",
9
+ "assistant.response": "编辑助手回复",
10
+ };
11
+
12
+ export function MessageEditDialog({
13
+ block,
14
+ onSave,
15
+ onClose,
16
+ }: {
17
+ block: EditableMessageBlock;
18
+ onSave: (text: string) => Promise<boolean>;
19
+ onClose: () => void;
20
+ }) {
21
+ const [text, setText] = useState(block.text);
22
+ const [saving, setSaving] = useState(false);
23
+ const inputRef = useRef<HTMLTextAreaElement | null>(null);
24
+
25
+ const autosize = (): void => {
26
+ const el = inputRef.current;
27
+ if (el === null) return;
28
+ el.style.height = "auto";
29
+ el.style.height = `${el.scrollHeight}px`;
30
+ };
31
+
32
+ const save = (): void => {
33
+ if (saving) return;
34
+ setSaving(true);
35
+ void onSave(text).then((applied) => {
36
+ if (applied) onClose();
37
+ else setSaving(false);
38
+ });
39
+ };
40
+
41
+ return (
42
+ <Modal
43
+ open
44
+ onClose={onClose}
45
+ title={BLOCK_TITLE[block.kind]}
46
+ closeLabel="关闭"
47
+ footer={
48
+ <div className={css.actions}>
49
+ <Button variant="outline" onClick={onClose} disabled={saving}>
50
+ 取消
51
+ </Button>
52
+ <Button variant="primary" onClick={save} disabled={saving}>
53
+ {saving ? "保存中…" : "保存"}
54
+ </Button>
55
+ </div>
56
+ }
57
+ >
58
+ <textarea
59
+ ref={inputRef}
60
+ className={css.input}
61
+ value={text}
62
+ onChange={(event) => {
63
+ setText(event.target.value);
64
+ autosize();
65
+ }}
66
+ autoFocus
67
+ rows={4}
68
+ />
69
+ </Modal>
70
+ );
71
+ }
@@ -0,0 +1,86 @@
1
+ /* Shared message IconActions row (user + assistant). Parent modules own
2
+ layout offsets via the composed className. Icons stay visible when mounted;
3
+ the time label is hover-revealed inside a data-time-hover-root scope. */
4
+
5
+ .actions {
6
+ display: flex;
7
+ align-items: center;
8
+ gap: 10px;
9
+ height: 28px;
10
+ }
11
+
12
+ /* Clock before icons (user figma 388:20051) / after (assistant 43:32997). */
13
+ .timeStart {
14
+ padding-right: 12px;
15
+ font-size: 14px;
16
+ line-height: 24px;
17
+ color: var(--dsw-alias-label-tertiary);
18
+ white-space: nowrap;
19
+ }
20
+
21
+ .timeEnd {
22
+ padding-left: 12px;
23
+ font-size: 14px;
24
+ line-height: 24px;
25
+ color: var(--dsw-alias-label-tertiary);
26
+ white-space: nowrap;
27
+ }
28
+
29
+ /* Separator between the clock and the run-time label (time · Ran for 15s). */
30
+ .runTimeDot {
31
+ margin: 0 10px;
32
+ }
33
+
34
+ /* Message containers opt in with data-time-hover-root: the time label fades
35
+ in on message hover (or keyboard focus within). Opacity keeps the layout
36
+ stable, and devices without hover keep the label always visible. */
37
+ @media (hover: hover) {
38
+ [data-time-hover-root] :is(.timeStart, .timeEnd) {
39
+ opacity: 0;
40
+ transition: opacity 80ms ease;
41
+ }
42
+
43
+ [data-time-hover-root]:hover :is(.timeStart, .timeEnd),
44
+ [data-time-hover-root]:focus-within :is(.timeStart, .timeEnd) {
45
+ opacity: 1;
46
+ }
47
+ }
48
+
49
+ .action {
50
+ display: inline-flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ width: 28px;
54
+ height: 28px;
55
+ padding: 6px;
56
+ border: none;
57
+ border-radius: 28px;
58
+ background: transparent;
59
+ color: var(--dsw-alias-label-tertiary);
60
+ cursor: pointer;
61
+ }
62
+
63
+ .action:hover {
64
+ background: var(--dsw-alias-interactive-bg-hover);
65
+ color: var(--dsw-alias-label-secondary);
66
+ }
67
+
68
+ /* Unavailable stays focusable and hoverable so Tooltip can explain why. */
69
+ .action[data-unavailable] {
70
+ cursor: default;
71
+ opacity: 0.4;
72
+ }
73
+
74
+ .action[data-unavailable]:hover {
75
+ background: transparent;
76
+ color: var(--dsw-alias-label-tertiary);
77
+ }
78
+
79
+ .visuallyHidden {
80
+ position: absolute;
81
+ width: 1px;
82
+ height: 1px;
83
+ overflow: hidden;
84
+ clip: rect(0 0 0 0);
85
+ white-space: nowrap;
86
+ }
@@ -0,0 +1,189 @@
1
+ // Shared IconActions chrome for user and assistant messages: copy
2
+ // live, optional branch wiring, and an optional date-aware clock.
3
+
4
+ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
5
+ import {
6
+ IconBranchOutline16,
7
+ IconCheckOutline16,
8
+ IconCopyOutline16,
9
+ IconEditOutline16,
10
+ IconRefreshOutline16,
11
+ Tooltip,
12
+ writeClipboard,
13
+ } from "@deepseek-ai/dsh-client-ui-primitives";
14
+ import type { ChatViewSlotProps } from "@deepseek-ai/dsh-client-ui-chat/client";
15
+ import {
16
+ formatLatencySeconds,
17
+ formatMessageClock,
18
+ formatRunDuration,
19
+ formatTokensPerSecond,
20
+ } from "./message-chrome.ts";
21
+ import { useCalendarDay } from "./use-calendar-day.ts";
22
+ import css from "./MessageIconActions.module.css";
23
+
24
+ export interface MessageIconActionsProps {
25
+ text: string;
26
+
27
+ time?: number | undefined;
28
+
29
+ runMs?: number | undefined;
30
+
31
+ ttftMs?: number | undefined;
32
+
33
+ tokensPerSecond?: number | undefined;
34
+
35
+ clock: "start" | "end";
36
+
37
+ onBranch?: (() => void) | undefined;
38
+
39
+ branchUnavailable?: boolean | undefined;
40
+
41
+ className?: string | undefined;
42
+
43
+ extraActions?: ReactNode;
44
+
45
+ onEdit?: (() => void) | undefined;
46
+
47
+ onRetry?: (() => void) | undefined;
48
+
49
+ t: ChatViewSlotProps["t"];
50
+ }
51
+
52
+ export function MessageIconActions({
53
+ text,
54
+ time,
55
+ runMs,
56
+ ttftMs,
57
+ tokensPerSecond,
58
+ clock,
59
+ onBranch,
60
+ branchUnavailable = false,
61
+ className,
62
+ extraActions,
63
+ onEdit,
64
+ onRetry,
65
+ t,
66
+ }: MessageIconActionsProps) {
67
+ const day = useCalendarDay();
68
+ const reasonId = useId();
69
+ // Same success chrome as CodeBlock: a short check swap after the write,
70
+ // gated so re-clicks during the window neither re-copy nor stack timers.
71
+ const [copied, setCopied] = useState(false);
72
+ const copyPending = useRef(false);
73
+ const copyTimer = useRef<number | null>(null);
74
+ const copyEpoch = useRef(0);
75
+ useEffect(
76
+ () => () => {
77
+ copyEpoch.current += 1;
78
+ copyPending.current = false;
79
+ if (copyTimer.current !== null) clearTimeout(copyTimer.current);
80
+ },
81
+ [],
82
+ );
83
+ const onCopy = useCallback(() => {
84
+ if (copied || copyPending.current) return;
85
+ const epoch = copyEpoch.current;
86
+ copyPending.current = true;
87
+ void writeClipboard(text).then((ok) => {
88
+ if (epoch !== copyEpoch.current) return;
89
+ copyPending.current = false;
90
+ if (!ok) return;
91
+ setCopied(true);
92
+ copyTimer.current = window.setTimeout(() => {
93
+ copyTimer.current = null;
94
+ setCopied(false);
95
+ }, 1000);
96
+ });
97
+ }, [copied, text]);
98
+ // The dot is decorative and stays hidden, but its margins separate the
99
+ // readings only on screen: without the flanking spaces a reader hears one
100
+ // run-on string ("Ran for 13sTTFT 0.2s12 tok/s") instead of three facts.
101
+ const clockEl =
102
+ time === undefined ? null : (
103
+ <span className={clock === "start" ? css.timeStart : css.timeEnd}>
104
+ {formatMessageClock(time, t, day)}
105
+ {runMs !== undefined && (
106
+ <>
107
+ {" "}
108
+ <span className={css.runTimeDot} aria-hidden>
109
+ ·
110
+ </span>{" "}
111
+ {t("message.ranFor", { duration: formatRunDuration(runMs, t) })}
112
+ </>
113
+ )}
114
+ {ttftMs !== undefined && (
115
+ <>
116
+ {" "}
117
+ <span className={css.runTimeDot} aria-hidden>
118
+ ·
119
+ </span>{" "}
120
+ {t("stats.ttftAverage", { duration: formatLatencySeconds(ttftMs) })}
121
+ </>
122
+ )}
123
+ {tokensPerSecond !== undefined && (
124
+ <>
125
+ {" "}
126
+ <span className={css.runTimeDot} aria-hidden>
127
+ ·
128
+ </span>{" "}
129
+ {t("message.tokensPerSecond", { tps: formatTokensPerSecond(tokensPerSecond) })}
130
+ </>
131
+ )}
132
+ </span>
133
+ );
134
+ return (
135
+ <div className={className === undefined ? css.actions : `${css.actions} ${className}`}>
136
+ {clock === "start" ? clockEl : null}
137
+ <Tooltip label={copied ? t("copied" as never) : t("copy" as never)} side="bottom">
138
+ <button
139
+ type="button"
140
+ className={css.action}
141
+ aria-label={copied ? t("copied" as never) : t("copy" as never)}
142
+ onClick={onCopy}
143
+ >
144
+ {copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
145
+ </button>
146
+ </Tooltip>
147
+ {extraActions}
148
+ {onEdit !== undefined && (
149
+ <Tooltip label="编辑" side="bottom">
150
+ <button type="button" className={css.action} aria-label="编辑" onClick={onEdit}>
151
+ <IconEditOutline16 />
152
+ </button>
153
+ </Tooltip>
154
+ )}
155
+ {onRetry !== undefined && (
156
+ <Tooltip label="重试此回合" side="bottom">
157
+ <button type="button" className={css.action} aria-label="重试此回合" onClick={onRetry}>
158
+ <IconRefreshOutline16 />
159
+ </button>
160
+ </Tooltip>
161
+ )}
162
+ {onBranch !== undefined && (
163
+ <Tooltip
164
+ label={branchUnavailable ? t("message.branchUnavailable") : t("message.branch")}
165
+ side="bottom"
166
+ >
167
+ {/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
168
+ <button
169
+ type="button"
170
+ className={css.action}
171
+ aria-label={t("message.branch")}
172
+ aria-disabled={branchUnavailable || undefined}
173
+ aria-describedby={branchUnavailable ? reasonId : undefined}
174
+ data-unavailable={branchUnavailable || undefined}
175
+ onClick={branchUnavailable ? undefined : onBranch}
176
+ >
177
+ <IconBranchOutline16 />
178
+ </button>
179
+ </Tooltip>
180
+ )}
181
+ {onBranch !== undefined && branchUnavailable && (
182
+ <span id={reasonId} className={css.visuallyHidden}>
183
+ {t("message.branchUnavailable")}
184
+ </span>
185
+ )}
186
+ {clock === "end" ? clockEl : null}
187
+ </div>
188
+ );
189
+ }
@@ -0,0 +1,290 @@
1
+ /* User bubble: right-aligned column (bubble + IconActions). Figma
2
+ User_Bubble/message_container 659:38813 — r22 fill, actions gap 6 below. */
3
+
4
+ .userRow {
5
+ display: flex;
6
+ flex-direction: column;
7
+ align-items: flex-end;
8
+ gap: 6px;
9
+ }
10
+
11
+ .userStack {
12
+ display: flex;
13
+ flex-direction: column;
14
+ align-items: flex-end;
15
+ gap: 8px;
16
+ min-width: 0;
17
+ max-width: min(525px, 82%);
18
+ }
19
+ .bubble {
20
+ /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
21
+ max-width: 100%;
22
+ background: var(--dsw-specific-bubble);
23
+ border-radius: 22px;
24
+ /* 44px single-line bubble: 24 line + 10 vertical padding each side. */
25
+ padding: 10px 16px;
26
+ font-size: 16px;
27
+ line-height: 24px;
28
+ color: var(--dsw-alias-label-primary);
29
+ }
30
+
31
+ .contextRow {
32
+ padding: 2px 0;
33
+ }
34
+
35
+ /* Compaction marker: one dim 24px row with a context icon at rest and a
36
+ hover/focus disclosure for the summary body. Dimmed title (not
37
+ label-primary) — the row is a boundary notice, not conversation content. */
38
+ .compactionRow {
39
+ padding: 2px 0;
40
+ }
41
+
42
+ .compactionButton {
43
+ display: flex;
44
+ align-items: center;
45
+ width: 100%;
46
+ height: 24px;
47
+ min-width: 0;
48
+ padding: 0;
49
+ border: none;
50
+ border-radius: 6px;
51
+ background: none;
52
+ color: inherit;
53
+ font: inherit;
54
+ text-align: left;
55
+ }
56
+
57
+ .compactionButton:not(:disabled) {
58
+ cursor: pointer;
59
+ }
60
+
61
+ .compactionButton:not(:disabled):hover {
62
+ background: var(--dsw-alias-interactive-bg-hover);
63
+ }
64
+
65
+ .compactionLeading {
66
+ flex: none;
67
+ display: inline-grid;
68
+ place-items: center;
69
+ width: 16px;
70
+ height: 16px;
71
+ margin-right: 6px;
72
+ color: var(--dsw-alias-label-secondary);
73
+ }
74
+
75
+ .compactionContextIcon,
76
+ .compactionDisclosureIcon {
77
+ display: inline-flex;
78
+ grid-area: 1 / 1;
79
+ align-items: center;
80
+ justify-content: center;
81
+ }
82
+
83
+ .compactionDisclosureIcon {
84
+ opacity: 0;
85
+ }
86
+
87
+ .compactionButton:not(:disabled):hover .compactionContextIcon,
88
+ .compactionButton:not(:disabled):focus-visible .compactionContextIcon {
89
+ opacity: 0;
90
+ }
91
+
92
+ .compactionButton:not(:disabled):hover .compactionDisclosureIcon,
93
+ .compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon {
94
+ opacity: 1;
95
+ }
96
+
97
+ .compactionTitle {
98
+ flex: none;
99
+ font-size: 14px;
100
+ line-height: 24px;
101
+ color: var(--dsw-alias-label-primary-dimmed);
102
+ }
103
+
104
+ .compactionSep {
105
+ flex: none;
106
+ width: 2px;
107
+ height: 2px;
108
+ margin: 0 8px;
109
+ border-radius: 1px;
110
+ background: var(--dsw-alias-label-caption);
111
+ }
112
+
113
+ .compactionSummary {
114
+ flex: 1 1 auto;
115
+ min-width: 0;
116
+ overflow: hidden;
117
+ color: var(--dsw-alias-label-tertiary);
118
+ font-size: 14px;
119
+ line-height: 24px;
120
+ text-overflow: ellipsis;
121
+ white-space: nowrap;
122
+ }
123
+
124
+ .compactionBody {
125
+ padding: 4px 0 4px 22px;
126
+ color: var(--dsw-alias-label-tertiary);
127
+ font-size: 14px;
128
+ line-height: 24px;
129
+ }
130
+
131
+ .retryRow {
132
+ color: var(--dsw-alias-label-tertiary);
133
+ font-size: 13px;
134
+ line-height: 20px;
135
+ }
136
+
137
+ .retrySummary {
138
+ display: inline-flex;
139
+ align-items: center;
140
+ width: fit-content;
141
+ padding: 2px 0;
142
+ gap: 7px;
143
+ border-radius: 3px;
144
+ color: inherit;
145
+ cursor: pointer;
146
+ list-style: none;
147
+ user-select: none;
148
+ }
149
+
150
+ .retrySummary::-webkit-details-marker {
151
+ display: none;
152
+ }
153
+
154
+ .retrySummary::after {
155
+ width: 6px;
156
+ height: 6px;
157
+ border-right: 1.5px solid currentcolor;
158
+ border-bottom: 1.5px solid currentcolor;
159
+ content: "";
160
+ opacity: 0.8;
161
+ transform: rotate(-45deg);
162
+ transition: transform 120ms ease;
163
+ }
164
+
165
+ .retrySummary:hover {
166
+ color: var(--dsw-alias-label-secondary);
167
+ }
168
+
169
+ .retrySummary:focus-visible {
170
+ outline: 1.5px solid var(--dsw-alias-button-info-fill);
171
+ outline-offset: 2px;
172
+ }
173
+
174
+ .retryText {
175
+ color: inherit;
176
+ }
177
+
178
+ .retryRow[data-active] .retryText {
179
+ background: linear-gradient(
180
+ 90deg,
181
+ var(--dsw-alias-label-tertiary) 0%,
182
+ var(--dsw-alias-label-tertiary) 40%,
183
+ var(--dsw-alias-label-secondary) 50%,
184
+ var(--dsw-alias-label-tertiary) 60%,
185
+ var(--dsw-alias-label-tertiary) 100%
186
+ );
187
+ background-position: 100% 50%;
188
+ background-size: 200% 100%;
189
+ background-clip: text;
190
+ color: transparent;
191
+ animation: retry-shimmer 1.6s ease-in-out infinite;
192
+ }
193
+
194
+ .retryRow[open] .retrySummary::after {
195
+ transform: rotate(45deg);
196
+ }
197
+
198
+ .retryDetails {
199
+ display: grid;
200
+ gap: 2px;
201
+ margin-top: 3px;
202
+ padding-left: 14px;
203
+ overflow-wrap: anywhere;
204
+ font-size: 12px;
205
+ line-height: 18px;
206
+ }
207
+
208
+ .retryDetailLabel {
209
+ color: var(--dsw-alias-label-secondary);
210
+ }
211
+
212
+ .turnErrorRow {
213
+ display: grid;
214
+ grid-template-columns: 10px minmax(0, 1fr) auto;
215
+ gap: 8px;
216
+ align-items: start;
217
+ padding: 2px 0;
218
+ font-size: 13px;
219
+ line-height: 20px;
220
+ }
221
+
222
+ .turnErrorDot {
223
+ margin-top: 5px;
224
+ }
225
+
226
+ .turnErrorCopy {
227
+ min-width: 0;
228
+ overflow-wrap: anywhere;
229
+ }
230
+
231
+ .turnErrorTitle {
232
+ margin-right: 6px;
233
+ color: var(--dsw-alias-state-error-primary);
234
+ font-weight: 600;
235
+ }
236
+
237
+ .turnErrorMessage {
238
+ color: var(--dsw-alias-label-secondary);
239
+ }
240
+
241
+ .turnErrorCode {
242
+ color: var(--dsw-alias-label-tertiary);
243
+ font: var(--dsw-font-markdown-code-block-small);
244
+ }
245
+
246
+ .maxTokensTitle {
247
+ margin-right: 6px;
248
+ color: var(--dsw-alias-state-warn-primary);
249
+ font-weight: 600;
250
+ }
251
+
252
+ @keyframes retry-shimmer {
253
+ from {
254
+ background-position: 100% 50%;
255
+ }
256
+
257
+ to {
258
+ background-position: 0 50%;
259
+ }
260
+ }
261
+
262
+ @media (prefers-reduced-motion: reduce) {
263
+ .retryRow[data-active] .retryText {
264
+ background: none;
265
+ color: inherit;
266
+ animation: none;
267
+ }
268
+ }
269
+
270
+ /* Reference chip projection inside a user bubble (`<skill>name</skill>` model
271
+ spans render as chips; free geometry — no textarea pairing here). */
272
+ .refChip {
273
+ display: inline-block;
274
+ margin: 0 2px;
275
+ padding: 0 8px;
276
+ border-radius: 6px;
277
+ background: rgba(97, 135, 216, 0.22);
278
+ color: var(--dsw-alias-label-primary);
279
+ font-size: 0.85em;
280
+ line-height: 1.6;
281
+ white-space: nowrap;
282
+ vertical-align: baseline;
283
+ }
284
+
285
+ /* Retry-confirmation modal action row. */
286
+ .confirmActions {
287
+ display: flex;
288
+ justify-content: flex-end;
289
+ gap: 8px;
290
+ }