@spzxg/cli-ui 0.6.5 → 0.6.7
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.
- package/CHANGELOG.md +55 -0
- package/README.md +109 -30
- package/dist/cli-ui-embed-CB7ibAtF.js +49558 -0
- package/dist/{cli-ui-index-D4qfPHMd.js → cli-ui-index-BmwOLWFI.js} +2 -2
- package/dist/{cli-ui-jszip.min-DC9898pC.js → cli-ui-jszip.min-BKNOJnhm.js} +1 -1
- package/dist/{cli-ui-jszip.min-DpEl0G1z.js → cli-ui-jszip.min-Cq0WjNYb.js} +2 -2
- package/dist/{cli-ui-xlsx-zs7oEtPj.js → cli-ui-xlsx-Sl7ypvSe.js} +1 -1
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +11 -8
- package/src/board/BrowserPane.tsx +198 -53
- package/src/board/CliBoard.tsx +128 -100
- package/src/board/FileViewer.tsx +276 -136
- package/src/board/FilesPane.tsx +129 -40
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +19 -7
- package/src/board/fileKind.ts +138 -0
- package/src/board/fileOps.ts +9 -2
- package/src/board/fileScope.ts +29 -0
- package/src/board/fileSync.ts +343 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +71 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +177 -70
- package/src/board/folderDownload.ts +39 -5
- package/src/board/frameQuote.ts +12 -10
- package/src/board/htmlReading.ts +148 -0
- package/src/board/textLoad.ts +18 -41
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +53 -11
- package/src/chat/CliChat.tsx +105 -55
- package/src/chat/Composer.tsx +55 -21
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +132 -42
- package/src/chat/draft.ts +5 -2
- package/src/core/blocks.ts +17 -5
- package/src/core/client.ts +88 -19
- package/src/core/conversation.ts +1 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/provider.tsx +7 -0
- package/src/core/store.ts +134 -36
- package/src/core/types.ts +31 -10
- package/src/index.ts +3 -0
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +18 -5
- package/src/operator/node/Chat.tsx +7 -8
- package/src/operator/node/Composer.tsx +99 -72
- package/src/operator/node/Orchestrator.tsx +1 -1
- package/src/operator/node/SessionRail.tsx +71 -32
- package/src/operator/node/SessionView.tsx +25 -25
- package/src/operator/node/Transcript.tsx +28 -11
- package/src/operator/node/boardBackend.ts +6 -23
- package/src/operator/node/boardEvents.ts +21 -3
- package/src/operator/node/boardState.ts +18 -1
- package/src/operator/node/boardWorkspace.ts +4 -1
- package/src/operator/node/composerDraft.ts +6 -2
- package/src/operator/node/format.ts +4 -2
- package/src/operator/node/liveStream.ts +14 -2
- package/src/operator/node/protocol/types.ts +2 -2
- package/src/operator/node/useTaskStream.ts +8 -5
- package/src/operator.base.css +148 -37
- package/src/operator.css +148 -37
- package/src/styles.css +274 -32
- package/src/ui/Markdown.tsx +2 -2
- package/src/ui/Popover.tsx +32 -14
- package/src/ui/QueuedMessages.tsx +69 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/TextBlock.tsx +6 -4
- package/src/ui/Transcript.tsx +27 -12
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/format.ts +3 -1
- package/src/ui/reference.ts +40 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
- package/dist/cli-ui-embed-BmLN2Jjg.js +0 -43290
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useId, useState, type ReactNode } from "react";
|
|
2
|
+
import { Icon } from "./icons";
|
|
3
|
+
import { Markdown } from "./Markdown";
|
|
4
|
+
import { referencePreview, referenceTitle } from "./reference";
|
|
5
|
+
|
|
6
|
+
/** A reference stays separate from editable text; expanding it never changes the payload. */
|
|
7
|
+
export function ReferenceCard({ title, text, onRemove, disabled, renderContent, className, removeLabel }: {
|
|
8
|
+
title: string; text: string; onRemove?: () => void; disabled?: boolean;
|
|
9
|
+
renderContent?: (text: string) => ReactNode; className?: string; removeLabel?: string;
|
|
10
|
+
}) {
|
|
11
|
+
const [open, setOpen] = useState(false);
|
|
12
|
+
const bodyId = useId();
|
|
13
|
+
const displayTitle = referenceTitle(title);
|
|
14
|
+
return <section className={`cli-reference${open ? " open" : ""}${className ? ` ${className}` : ""}`} aria-label={`引用 ${displayTitle}`}>
|
|
15
|
+
<div className="cli-reference-head">
|
|
16
|
+
<button type="button" className="cli-reference-toggle" aria-expanded={open} aria-controls={bodyId} aria-label={`${open ? "收起" : "展开"}引用 ${displayTitle}`} onClick={() => setOpen(value => !value)}>
|
|
17
|
+
<span className="cli-reference-icon" aria-hidden="true">{Icon.doc()}</span>
|
|
18
|
+
<span className="cli-reference-kind">引用</span>
|
|
19
|
+
<span className="cli-reference-title cli-ref-l" title={title}>{displayTitle}</span>
|
|
20
|
+
<span className="cli-reference-state">{open ? "收起" : "展开"}</span>
|
|
21
|
+
<span className="cli-reference-chevron" aria-hidden="true">{open ? Icon.chevUp() : Icon.chevDown()}</span>
|
|
22
|
+
</button>
|
|
23
|
+
{onRemove && <button type="button" className="cli-reference-remove" disabled={disabled} aria-label={removeLabel ?? `移除引用 ${title}`} title="移除引用" onClick={onRemove}>{Icon.x()}</button>}
|
|
24
|
+
</div>
|
|
25
|
+
{open ? <div className="cli-reference-body" id={bodyId}>{text.length > 50_000 ? <pre>{text}</pre> : renderContent ? renderContent(text) : <Markdown>{text}</Markdown>}</div>
|
|
26
|
+
: <div className="cli-reference-preview">{referencePreview(text) || "引用内容"}</div>}
|
|
27
|
+
</section>;
|
|
28
|
+
}
|
package/src/ui/TextBlock.tsx
CHANGED
|
@@ -14,6 +14,8 @@ const PREVIEW_LINES = 3;
|
|
|
14
14
|
export const TextBlockCard = memo(function TextBlockCard({ block, onRemove }: { block: TextBlock; onRemove?: () => void }) {
|
|
15
15
|
const [open, setOpen] = useState(false);
|
|
16
16
|
const [limit, setLimit] = useState(EXPANDED_STEP);
|
|
17
|
+
const staged = /^\[文件原件:.*\]\(cli-upload:\/\//.test(block.text);
|
|
18
|
+
const original = staged || /^\[Attached (?:file|image): [^\r\n]+\]$/.test(block.text);
|
|
17
19
|
const chars = charCount(block.text);
|
|
18
20
|
const lines = block.text ? block.text.split("\n").length : 0;
|
|
19
21
|
const preview = block.text.split("\n", PREVIEW_LINES + 1).slice(0, PREVIEW_LINES).join("\n").slice(0, 400);
|
|
@@ -24,14 +26,14 @@ export const TextBlockCard = memo(function TextBlockCard({ block, onRemove }: {
|
|
|
24
26
|
<div className="cli-block-h">
|
|
25
27
|
<span className="cli-block-ic">{block.kind === "file" ? Icon.doc() : block.kind === "context" || block.kind === "viewing" ? Icon.board() : block.kind === "background" ? Icon.flag() : Icon.clip()}</span>
|
|
26
28
|
<span className="cli-block-name" title={block.name}>{block.name}</span>
|
|
27
|
-
<span className="cli-block-meta">{fmtCount(chars)} 字 · {fmtCount(lines)}
|
|
29
|
+
<span className="cli-block-meta">{original ? staged ? "原件已暂存" : "已保存到工作区" : `${fmtCount(chars)} 字 · ${fmtCount(lines)} 行`}</span>
|
|
28
30
|
<span className="cli-block-sp" />
|
|
29
|
-
<button type="button" className="cli-block-btn" onClick={() => { setOpen((o) => !o); setLimit(EXPANDED_STEP); }}>
|
|
31
|
+
{!original && <button type="button" className="cli-block-btn" onClick={() => { setOpen((o) => !o); setLimit(EXPANDED_STEP); }}>
|
|
30
32
|
{open ? "收起" : "展开"}
|
|
31
|
-
</button>
|
|
33
|
+
</button>}
|
|
32
34
|
{onRemove && <button type="button" className="cli-block-x" title="移除" aria-label={`移除 ${block.name}`} onClick={onRemove}>{Icon.x()}</button>}
|
|
33
35
|
</div>
|
|
34
|
-
<pre className="cli-block-body">{shown}{!open && block.text.length > preview.length ? "…" : ""}</pre>
|
|
36
|
+
{!original && <pre className="cli-block-body">{shown}{!open && block.text.length > preview.length ? "…" : ""}</pre>}
|
|
35
37
|
{open && rest > 0 && (
|
|
36
38
|
<button type="button" className="cli-block-more" onClick={() => setLimit((n) => n + EXPANDED_STEP * 5)}>
|
|
37
39
|
还有 {fmtCount(rest)} 字,继续显示
|
package/src/ui/Transcript.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// 转录里的一行。移植自 operator-oss/app/orchestrator/Transcript.tsx,类名、结构、文案保持一致;
|
|
2
2
|
// 去掉了对话任务用不到的部分(/clear、重新登录、浏览器交还……这些动作 center 不提供)。
|
|
3
|
-
import { memo, useState } from "react";
|
|
3
|
+
import { memo, useContext, useId, useState } from "react";
|
|
4
|
+
import { FileOpenContext, FileLinkContext } from "./fileLinks";
|
|
4
5
|
import type { AskAnswers, AskQuestion, InputRequest, Msg, ToolData, ToolPeek } from "../core/types";
|
|
5
6
|
import { splitMessage } from "../core/blocks";
|
|
6
7
|
import { Icon } from "./icons";
|
|
@@ -9,6 +10,8 @@ import { clockTime, diffCls, splitAttachments, type MsgAttachment } from "./form
|
|
|
9
10
|
import { Avatar } from "./shared";
|
|
10
11
|
import { toolDataOf } from "./transcriptModel";
|
|
11
12
|
import { TextBlockCard, isLongMessage } from "./TextBlock";
|
|
13
|
+
import { ReferenceCard } from "./ReferenceCard";
|
|
14
|
+
import { splitQuotedMessage } from "./reference";
|
|
12
15
|
|
|
13
16
|
// Operator 写进转录的几类系统提示(取开头一段做匹配):按「上下文超限」那种醒目样式显示。
|
|
14
17
|
const LOUD_NOTICES = [
|
|
@@ -60,21 +63,22 @@ function PeekView({ peek, expandable, onExpand }: { peek: ToolPeek; expandable:
|
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
function ToolView({ data }: { data: ToolData }) {
|
|
63
|
-
const [open, setOpen] = useState(
|
|
66
|
+
const [open, setOpen] = useState<boolean | null>(null);
|
|
67
|
+
const bodyId = useId();
|
|
64
68
|
const hasDiff = !!data.diff?.length;
|
|
65
69
|
const expandable = !!(data.detail || hasDiff || data.result !== undefined);
|
|
66
70
|
// 失败的工具自动展开输出,和 Claude Code 一样。
|
|
67
|
-
const showBody = open
|
|
71
|
+
const showBody = open ?? (!!data.isError && data.result !== undefined);
|
|
68
72
|
return (
|
|
69
73
|
<div className="tool">
|
|
70
|
-
<button className="tool-h" style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen(
|
|
74
|
+
<button className="tool-h" title={data.title} aria-expanded={expandable ? showBody : undefined} aria-controls={expandable ? bodyId : undefined} style={{ cursor: expandable ? "pointer" : "default" }} onClick={() => expandable && setOpen(!showBody)}>
|
|
71
75
|
{expandable && <span className={`tchev ${showBody ? "open" : ""}`}>{Icon.chevRight()}</span>}
|
|
72
76
|
<span className="tg">{data.title}</span>
|
|
73
77
|
{data.result !== undefined && <span className={data.isError ? "tx" : "tcheck"}>{data.isError ? Icon.x() : Icon.check()}</span>}
|
|
74
78
|
</button>
|
|
75
79
|
{data.peek && !showBody && <PeekView peek={data.peek} expandable={expandable} onExpand={() => setOpen(true)} />}
|
|
76
80
|
{showBody && (
|
|
77
|
-
<div className="tool-body">
|
|
81
|
+
<div className="tool-body" id={bodyId}>
|
|
78
82
|
{data.detail && <pre className="tool-pre">{data.detail}</pre>}
|
|
79
83
|
{hasDiff && <pre className="tool-pre diff">{data.diff!.map((l, i) => <div className={`dl ${diffCls(l.sign)}`} key={i}>{l.sign} {l.text}</div>)}</pre>}
|
|
80
84
|
{data.result !== undefined && (
|
|
@@ -186,8 +190,13 @@ function InputView({ input, agentLabel, onSubmit, onBrowser }: { input: InputReq
|
|
|
186
190
|
if (!complete || busy || !onSubmit) return;
|
|
187
191
|
setBusy(true);
|
|
188
192
|
setErr(null);
|
|
189
|
-
|
|
190
|
-
|
|
193
|
+
try {
|
|
194
|
+
const e = await onSubmit(input.id, vals.map((v, i) => input.fields[i].secret ? v : v.trim()));
|
|
195
|
+
if (e) { setErr(e); setBusy(false); }
|
|
196
|
+
} catch (error) {
|
|
197
|
+
setErr(error instanceof Error ? error.message : "提交失败,请重试");
|
|
198
|
+
setBusy(false);
|
|
199
|
+
}
|
|
191
200
|
};
|
|
192
201
|
return (
|
|
193
202
|
<div className="ask">
|
|
@@ -265,18 +274,19 @@ function HandoffLine({ text, onBrowser, onHandback }: { text: string; onBrowser?
|
|
|
265
274
|
// 附件条:Operator 的用户消息里,图片 / 大段粘贴以「[Attached image|file: 路径]」标记行出现。
|
|
266
275
|
// 能拿到文件地址时(attachmentUrl)显示缩略图 / 可点的文件片,拿不到时显示不可点的片。
|
|
267
276
|
function AttachmentStrip({ items, attachmentUrl }: { items: MsgAttachment[]; attachmentUrl?: (a: MsgAttachment) => string | null }) {
|
|
277
|
+
const open = useContext(FileOpenContext), resolve = useContext(FileLinkContext);
|
|
268
278
|
if (!items.length) return null;
|
|
269
279
|
return (
|
|
270
280
|
<div className="msg-attachments">
|
|
271
281
|
{items.map((a, i) => {
|
|
272
|
-
const url = attachmentUrl?.(a) ?? null;
|
|
282
|
+
const url = attachmentUrl?.(a) ?? resolve?.(a.path, a.kind === "image" ? "image" : "link") ?? null;
|
|
273
283
|
if (a.kind === "image" && url) {
|
|
274
284
|
return <a key={i} href={url} target="_blank" rel="noreferrer" title="查看原图"><img src={url} alt="附件图片" loading="lazy" /></a>;
|
|
275
285
|
}
|
|
276
286
|
const label = a.kind === "image" ? "附件图片" : "附件文件";
|
|
277
287
|
return url
|
|
278
|
-
? <a key={i} href={url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`}>{Icon.clip()} <span>{label}</span></a>
|
|
279
|
-
: <span key={i} className="file-chip" title={a.name}>{Icon.clip()} <span>{label}</span></span>;
|
|
288
|
+
? <a key={i} href={url} target="_blank" rel="noreferrer" className="file-chip" title={`打开 ${a.name}`} onClick={event => { if (!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey && event.button === 0 && open?.(a.path)) event.preventDefault(); }}>{Icon.clip()} <span>{a.kind === "file" ? a.name : label}</span></a>
|
|
289
|
+
: open && a.kind === "file" ? <button key={i} className="file-chip" onClick={() => open(a.path)} title={`打开 ${a.name}`}>{Icon.clip()} <span>{a.name}</span></button> : <span key={i} className="file-chip" title={a.name}>{Icon.clip()} <span>{label}</span></span>;
|
|
280
290
|
})}
|
|
281
291
|
</div>
|
|
282
292
|
);
|
|
@@ -290,13 +300,18 @@ function UserBody({ content, attachmentUrl, viewing = true }: { content: string;
|
|
|
290
300
|
return (
|
|
291
301
|
<>
|
|
292
302
|
{parts.map((part, i) => part.kind === "block"
|
|
293
|
-
? <TextBlockCard key={i} block={part.block} />
|
|
294
|
-
: <
|
|
303
|
+
? part.block.kind === "context" ? <ReferenceCard key={i} title="参考内容" text={part.block.text.replace(/^用户在右侧看板里引用了下面这些,供这条消息参考:\n/, "")} /> : <TextBlockCard key={i} block={part.block} />
|
|
304
|
+
: <QuotedText key={i} text={part.text} />)}
|
|
295
305
|
<AttachmentStrip items={attachments} attachmentUrl={attachmentUrl} />
|
|
296
306
|
</>
|
|
297
307
|
);
|
|
298
308
|
}
|
|
299
309
|
|
|
310
|
+
function QuotedText({ text }: { text: string }) {
|
|
311
|
+
const quoted = splitQuotedMessage(text);
|
|
312
|
+
return quoted ? <><ReferenceCard title={quoted.quote.author} text={quoted.quote.text} />{quoted.text && <LongText text={quoted.text} />}</> : <LongText text={text} />;
|
|
313
|
+
}
|
|
314
|
+
|
|
300
315
|
function LongText({ text }: { text: string }) {
|
|
301
316
|
const [open, setOpen] = useState(false);
|
|
302
317
|
if (!isLongMessage(text)) return <Markdown>{text}</Markdown>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { fmtSize } from "../board/fileKind";
|
|
2
|
+
import { Icon } from "./icons";
|
|
3
|
+
|
|
4
|
+
export type UploadProgressStatus = "queued" | "checking" | "uploading" | "verifying" | "ready" | "cancelled" | "error";
|
|
5
|
+
export interface UploadProgressProps {
|
|
6
|
+
name: string;
|
|
7
|
+
status: UploadProgressStatus;
|
|
8
|
+
bytes?: number;
|
|
9
|
+
total?: number;
|
|
10
|
+
error?: string;
|
|
11
|
+
onCancel?: () => void;
|
|
12
|
+
onRetry?: () => void;
|
|
13
|
+
onRemove?: () => void;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
const labels: Record<UploadProgressStatus, string> = { queued: "等待上传", checking: "校验中", uploading: "上传中", verifying: "确认中", ready: "已上传", cancelled: "已取消", error: "上传失败" };
|
|
18
|
+
|
|
19
|
+
/** Presentation only: the upload owner controls status, retries and cancellation. */
|
|
20
|
+
export function UploadProgress({ name, status, bytes, total, error, onCancel, onRetry, onRemove, disabled, className }: UploadProgressProps) {
|
|
21
|
+
const size = typeof total === "number" && Number.isFinite(total) && total >= 0 ? Math.floor(total) : undefined;
|
|
22
|
+
const received = typeof bytes === "number" && Number.isFinite(bytes) ? Math.max(0, Math.min(Math.floor(bytes), size ?? Infinity)) : undefined;
|
|
23
|
+
const active = status === "queued" || status === "checking" || status === "uploading" || status === "verifying";
|
|
24
|
+
const percentage = status === "ready" ? 100 : status === "uploading" && received !== undefined && size !== undefined && size > 0 ? Math.floor(received / size * 100) : undefined;
|
|
25
|
+
const count = status === "ready" && size !== undefined ? fmtSize(size) : received !== undefined ? `${fmtSize(received)}${size !== undefined ? ` / ${fmtSize(size)}` : ""}` : size !== undefined ? fmtSize(size) : "";
|
|
26
|
+
const label = labels[status];
|
|
27
|
+
return <div className={`cli-upload-progress${className ? ` ${className}` : ""}`} data-upload-status={status}>
|
|
28
|
+
<span className="cli-upload-icon" aria-hidden="true">{Icon.doc()}</span>
|
|
29
|
+
<div className="cli-upload-body">
|
|
30
|
+
<div className="cli-upload-head">
|
|
31
|
+
<span className="cli-upload-name" title={name}>{name}</span>
|
|
32
|
+
<span className="cli-upload-status" role="status" aria-live="polite" aria-atomic="true"><span className="cli-upload-sr">{name}:</span>{label}</span>
|
|
33
|
+
</div>
|
|
34
|
+
{(count || percentage !== undefined) && <div className="cli-upload-meta"><span>{count}</span>{percentage !== undefined && <span aria-hidden="true">{percentage}%</span>}</div>}
|
|
35
|
+
{(active || status === "ready") && <div className={`cli-upload-track${percentage === undefined ? " indeterminate" : ""}`} role="progressbar" aria-label={`上传进度:${name}`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percentage} aria-valuetext={`${label}${count ? `,${count}` : ""}`}>
|
|
36
|
+
<span className="cli-upload-fill" style={percentage === undefined ? undefined : { width: `${percentage}%` }} />
|
|
37
|
+
</div>}
|
|
38
|
+
{status === "error" && error && <div className="cli-upload-error" role="alert">{error}</div>}
|
|
39
|
+
</div>
|
|
40
|
+
{((active && onCancel) || (!active && (onRemove || (status === "error" || status === "cancelled") && onRetry))) && <div className="cli-upload-actions">
|
|
41
|
+
{active && onCancel && <button type="button" disabled={disabled} className="cli-upload-action" title="取消上传" aria-label={`取消上传 ${name}`} onClick={onCancel}>{Icon.x()}</button>}
|
|
42
|
+
{(status === "error" || status === "cancelled") && onRetry && <button type="button" disabled={disabled} className="cli-upload-action cli-upload-retry" aria-label={`重试上传 ${name}`} onClick={onRetry}>重试</button>}
|
|
43
|
+
{!active && onRemove && <button type="button" disabled={disabled} className="cli-upload-action" title="移除上传记录" aria-label={`移除上传记录 ${name}`} onClick={onRemove}>{Icon.x()}</button>}
|
|
44
|
+
</div>}
|
|
45
|
+
</div>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
2
|
+
import { Icon } from "./icons";
|
|
3
|
+
|
|
4
|
+
const useLayout = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
5
|
+
|
|
6
|
+
export function useWorkspaceDrawer(ref: RefObject<HTMLElement | null>, forced = false) {
|
|
7
|
+
const [narrow, setNarrow] = useState(false);
|
|
8
|
+
useLayout(() => {
|
|
9
|
+
const element = ref.current;
|
|
10
|
+
if (!element) return;
|
|
11
|
+
const measure = () => { const width = element.getBoundingClientRect().width; if (width > 0) setNarrow(width <= 640); };
|
|
12
|
+
measure();
|
|
13
|
+
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
|
|
14
|
+
resize?.observe(element);
|
|
15
|
+
window.addEventListener("resize", measure);
|
|
16
|
+
return () => { resize?.disconnect(); window.removeEventListener("resize", measure); };
|
|
17
|
+
}, [ref]);
|
|
18
|
+
return forced || narrow;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Keep the board in the same React position when it moves between desktop and mobile layouts. */
|
|
22
|
+
export function WorkspaceDrawer({ enabled, open, onClose, children }: {
|
|
23
|
+
enabled: boolean; open: boolean; onClose: () => void; children: ReactNode;
|
|
24
|
+
}) {
|
|
25
|
+
const panel = useRef<HTMLDivElement>(null);
|
|
26
|
+
const closeButton = useRef<HTMLButtonElement>(null);
|
|
27
|
+
const close = useRef(onClose);
|
|
28
|
+
close.current = onClose;
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!enabled || !open) return;
|
|
31
|
+
const previous = document.activeElement as HTMLElement | null;
|
|
32
|
+
closeButton.current?.focus({ preventScroll: true });
|
|
33
|
+
const onKey = (event: KeyboardEvent) => {
|
|
34
|
+
if (event.defaultPrevented) return;
|
|
35
|
+
// Menus rendered into document.body handle their own Escape/Tab first.
|
|
36
|
+
const dialog = (document.activeElement as HTMLElement | null)?.closest('[role="dialog"]');
|
|
37
|
+
if (dialog && dialog !== panel.current) return;
|
|
38
|
+
if (event.key === "Escape") { event.preventDefault(); close.current(); return; }
|
|
39
|
+
if (event.key !== "Tab" || !panel.current) return;
|
|
40
|
+
const items = [...panel.current.querySelectorAll<HTMLElement>('button:not(:disabled),a[href],input:not(:disabled),textarea:not(:disabled),select:not(:disabled),[tabindex="0"]')]
|
|
41
|
+
.filter(item => !item.closest('[hidden],[inert]') && getComputedStyle(item).display !== "none" && getComputedStyle(item).visibility !== "hidden");
|
|
42
|
+
const first = items[0], last = items.at(-1);
|
|
43
|
+
if (!first || !last) return;
|
|
44
|
+
if (!panel.current.contains(document.activeElement) || (event.shiftKey && document.activeElement === first)) { event.preventDefault(); (event.shiftKey ? last : first).focus({ preventScroll: true }); }
|
|
45
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus({ preventScroll: true }); }
|
|
46
|
+
};
|
|
47
|
+
window.addEventListener("keydown", onKey);
|
|
48
|
+
return () => {
|
|
49
|
+
window.removeEventListener("keydown", onKey);
|
|
50
|
+
const current = document.activeElement;
|
|
51
|
+
if ((current === document.body || panel.current?.contains(current)) && previous?.isConnected && !previous.closest('[hidden],[inert]')) previous.focus({ preventScroll: true });
|
|
52
|
+
};
|
|
53
|
+
}, [enabled, open]);
|
|
54
|
+
return <div className={enabled ? "workspace-drawer-layer" : "workspace-board-slot"} hidden={enabled && !open}>
|
|
55
|
+
<button type="button" className="workspace-drawer-scrim" aria-label="收起工作区遮罩" tabIndex={-1} hidden={!enabled} onClick={onClose} />
|
|
56
|
+
<div ref={panel} className={enabled ? "workspace-drawer-panel" : "workspace-board-slot"} role={enabled && open ? "dialog" : undefined} aria-modal={enabled && open ? true : undefined} aria-label={enabled ? "工作区看板" : undefined}>
|
|
57
|
+
<div className="workspace-drawer-heading" hidden={!enabled}>
|
|
58
|
+
<span>工作区</span>
|
|
59
|
+
<button ref={closeButton} type="button" className="workspace-drawer-close" aria-label="关闭工作区" title="关闭工作区" onClick={onClose}>{Icon.x()}</button>
|
|
60
|
+
</div>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
</div>;
|
|
64
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/** A scrollable header shared by the conversation and its existing board tabs. */
|
|
4
|
+
export function WorkspaceTabs({ title, subtitle, chatActive, onChat, onTabsHost }: {
|
|
5
|
+
title: string;
|
|
6
|
+
subtitle?: ReactNode;
|
|
7
|
+
chatActive: boolean;
|
|
8
|
+
onChat: () => void;
|
|
9
|
+
onTabsHost: (host: HTMLDivElement | null) => void;
|
|
10
|
+
}) {
|
|
11
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
12
|
+
useLayoutEffect(() => {
|
|
13
|
+
const box = ref.current;
|
|
14
|
+
if (!box) return;
|
|
15
|
+
let focused: HTMLElement | null = null;
|
|
16
|
+
const onFocus = (event: FocusEvent) => { focused = event.target as HTMLElement; };
|
|
17
|
+
const tabs = () => [...box.querySelectorAll<HTMLButtonElement>('button[role="tab"]')].filter(tab => !tab.disabled);
|
|
18
|
+
const reveal = (tab: HTMLElement) => {
|
|
19
|
+
const outer = box.getBoundingClientRect(), inner = tab.getBoundingClientRect();
|
|
20
|
+
if (inner.left < outer.left) box.scrollLeft -= outer.left - inner.left + 8;
|
|
21
|
+
else if (inner.right > outer.right) box.scrollLeft += inner.right - outer.right + 8;
|
|
22
|
+
};
|
|
23
|
+
const sync = () => {
|
|
24
|
+
const items = tabs(), active = items.find(tab => tab.getAttribute("aria-selected") === "true") ?? items[0];
|
|
25
|
+
for (const item of items) item.tabIndex = item === active ? 0 : -1;
|
|
26
|
+
if (active) reveal(active);
|
|
27
|
+
if (active && focused && !focused.isConnected && document.activeElement === document.body) active.focus({ preventScroll: true });
|
|
28
|
+
};
|
|
29
|
+
// Board tabs are portals: listen on the DOM container so both adapters share keyboard behavior.
|
|
30
|
+
const onKey = (event: KeyboardEvent) => {
|
|
31
|
+
const items = tabs(), index = items.indexOf(event.target as HTMLButtonElement);
|
|
32
|
+
if (index < 0 || event.altKey || event.metaKey || event.ctrlKey) return;
|
|
33
|
+
let next: number;
|
|
34
|
+
if (event.key === "ArrowRight") next = (index + 1) % items.length;
|
|
35
|
+
else if (event.key === "ArrowLeft") next = (index - 1 + items.length) % items.length;
|
|
36
|
+
else if (event.key === "Home") next = 0;
|
|
37
|
+
else if (event.key === "End") next = items.length - 1;
|
|
38
|
+
else return;
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
for (const item of items) item.tabIndex = item === items[next] ? 0 : -1;
|
|
41
|
+
items[next].focus({ preventScroll: true });
|
|
42
|
+
reveal(items[next]);
|
|
43
|
+
};
|
|
44
|
+
sync();
|
|
45
|
+
box.addEventListener("keydown", onKey);
|
|
46
|
+
box.addEventListener("focusin", onFocus);
|
|
47
|
+
const mutations = new MutationObserver(sync);
|
|
48
|
+
mutations.observe(box, { childList: true, subtree: true, attributes: true, attributeFilter: ["aria-selected", "disabled"] });
|
|
49
|
+
const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(sync);
|
|
50
|
+
resize?.observe(box);
|
|
51
|
+
return () => { box.removeEventListener("keydown", onKey); box.removeEventListener("focusin", onFocus); mutations.disconnect(); resize?.disconnect(); };
|
|
52
|
+
}, []);
|
|
53
|
+
return <div ref={ref} className="sh-main workspace-tabs" role="tablist" aria-label="对话与工作区">
|
|
54
|
+
<button type="button" className={`workspace-chat-tab${chatActive ? " on" : ""}`} role="tab" aria-selected={chatActive} aria-label={`对话:${title}`} title={title} onClick={onChat}>
|
|
55
|
+
{subtitle && <span className="workspace-chat-subtitle">{subtitle}</span>}
|
|
56
|
+
<span className="workspace-chat-title"><span className="workspace-chat-kind">对话</span><span>{title}</span></span>
|
|
57
|
+
</button>
|
|
58
|
+
<div className="workspace-board-tabs-host" ref={onTabsHost} />
|
|
59
|
+
</div>;
|
|
60
|
+
}
|
package/src/ui/format.ts
CHANGED
|
@@ -90,7 +90,9 @@ export function splitAttachments(content: string): { text: string; attachments:
|
|
|
90
90
|
for (const line of content.split("\n")) {
|
|
91
91
|
const m = ATTACHMENT_RE.exec(line.trim());
|
|
92
92
|
const parts = m ? m[2].split(/[\\/]/).filter(Boolean) : [];
|
|
93
|
-
|
|
93
|
+
const upload = parts.length >= 3 && parts[parts.length - 3] === "uploads";
|
|
94
|
+
const managed = parts.length >= 4 && parts[parts.length - 4] === ".workspace" && parts[parts.length - 3] === "attachments";
|
|
95
|
+
if (m && (upload || managed) && parts.slice(-2).every(part => part !== "." && part !== "..")) {
|
|
94
96
|
const [taskId, file] = parts.slice(-2);
|
|
95
97
|
attachments.push({ path: m[2], kind: m[1] === "image" ? "image" : "file", name: file, taskId });
|
|
96
98
|
} else {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** A compact display-only excerpt. The original reference is kept for sending. */
|
|
2
|
+
export function referencePreview(text: string): string {
|
|
3
|
+
const plain = text.slice(0, 4_000)
|
|
4
|
+
.replace(/^文件修订:[a-f0-9]{64}\n/gm, "")
|
|
5
|
+
.replace(/^\s*(?:```|~~~)[^\n]*$/gm, "")
|
|
6
|
+
.replace(/!\[([^\]]*)\]\([^\n)]*\)/g, "$1")
|
|
7
|
+
.replace(/\[([^\]]+)\]\([^\n)]*\)/g, "$1")
|
|
8
|
+
.replace(/^\s{0,3}#{1,6}\s+/gm, "")
|
|
9
|
+
.replace(/^\s*(?:>\s*)+/gm, "")
|
|
10
|
+
.replace(/^\s*(?:[-*+] |\d+[.)] )/gm, "")
|
|
11
|
+
.replace(/^\s*\|?[ :|-]+\|\s*$/gm, "")
|
|
12
|
+
.replace(/\*\*|__|~~|`/g, "")
|
|
13
|
+
.replace(/\*([^*\n]+)\*/g, "$1")
|
|
14
|
+
.replace(/\|/g, " · ")
|
|
15
|
+
.replace(/\s+/g, " ").trim();
|
|
16
|
+
return plain.length > 240 ? `${plain.slice(0, 240)}…` : plain;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Shorten known file-reference headings, while leaving URLs and agent names intact. */
|
|
20
|
+
export function referenceTitle(title: string): string {
|
|
21
|
+
const file = /^(文件|文件片段) (.+)$/.exec(title);
|
|
22
|
+
const workspaceFile = /^(?:当前工作区文件|来源任务\s*.+?\s*的工作区文件) (.+)$/.exec(title);
|
|
23
|
+
const path = file?.[2] ?? workspaceFile?.[1];
|
|
24
|
+
if (!path) return title;
|
|
25
|
+
const name = path.split(/[\\/]/).filter(Boolean).pop();
|
|
26
|
+
return name ? `${file?.[1] ?? "文件"} ${name}` : title;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Only the leading format emitted by node composerDraft.quoteText is a reference. */
|
|
30
|
+
export function splitQuotedMessage(content: string): { quote: { author: string; text: string }; text: string } | null {
|
|
31
|
+
const lines = content.split("\n");
|
|
32
|
+
const heading = /^引用 ([^\r\n]+):$/.exec(lines[0]);
|
|
33
|
+
if (!heading) return null;
|
|
34
|
+
let index = 1;
|
|
35
|
+
const quoted: string[] = [];
|
|
36
|
+
while (lines[index]?.startsWith("> ")) quoted.push(lines[index++].slice(2));
|
|
37
|
+
if (!quoted.length || (index < lines.length && lines[index] !== "")) return null;
|
|
38
|
+
if (lines[index] === "") index++;
|
|
39
|
+
return { quote: { author: heading[1], text: quoted.join("\n") }, text: lines.slice(index).join("\n") };
|
|
40
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useLayoutEffect, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/** Keep the fixed composer column aligned with a transcript using classic scrollbars. */
|
|
4
|
+
export function useChatScrollbar(ref: RefObject<HTMLElement | null>, scope?: string | null) {
|
|
5
|
+
useLayoutEffect(() => {
|
|
6
|
+
const element = ref.current;
|
|
7
|
+
const column = element?.closest<HTMLElement>("[data-chat-layout]");
|
|
8
|
+
if (!element || !column) return;
|
|
9
|
+
const property = "--cli-chat-scrollbar";
|
|
10
|
+
const previous = column.style.getPropertyValue(property);
|
|
11
|
+
const priority = column.style.getPropertyPriority(property);
|
|
12
|
+
let active = true, written = "";
|
|
13
|
+
const measure = () => {
|
|
14
|
+
if (!active) return;
|
|
15
|
+
written = `${Math.max(0, element.offsetWidth - element.clientWidth)}px`;
|
|
16
|
+
if (column.style.getPropertyValue(property) !== written) column.style.setProperty(property, written);
|
|
17
|
+
};
|
|
18
|
+
measure();
|
|
19
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
|
|
20
|
+
observer?.observe(element);
|
|
21
|
+
return () => {
|
|
22
|
+
active = false;
|
|
23
|
+
observer?.disconnect();
|
|
24
|
+
if (column.style.getPropertyValue(property) === written) {
|
|
25
|
+
if (previous) column.style.setProperty(property, previous, priority); else column.style.removeProperty(property);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}, [ref, scope]);
|
|
29
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
/** Keep both composers between two and eight visual lines without changing focus or selection. */
|
|
4
|
+
export function useComposerAutosize(ref: RefObject<HTMLTextAreaElement | null>, value: string) {
|
|
5
|
+
const resize = useCallback(() => {
|
|
6
|
+
const element = ref.current;
|
|
7
|
+
if (!element || element.clientWidth === 0) return;
|
|
8
|
+
const style = window.getComputedStyle(element);
|
|
9
|
+
const lineHeight = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) || 16) * 1.6;
|
|
10
|
+
const padding = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
|
|
11
|
+
const border = (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.borderBottomWidth) || 0);
|
|
12
|
+
const borderBox = style.boxSizing === "border-box";
|
|
13
|
+
const extra = borderBox ? padding + border : 0;
|
|
14
|
+
const minimum = Math.ceil(lineHeight * 2 + extra);
|
|
15
|
+
const maximum = Math.ceil(lineHeight * 8 + extra);
|
|
16
|
+
const scrollTop = element.scrollTop;
|
|
17
|
+
element.style.minHeight = `${minimum}px`;
|
|
18
|
+
element.style.maxHeight = `${maximum}px`;
|
|
19
|
+
element.style.overflowY = "hidden";
|
|
20
|
+
element.style.height = "auto";
|
|
21
|
+
const contentHeight = element.value ? element.scrollHeight + (borderBox ? border : -padding) : minimum;
|
|
22
|
+
element.style.height = `${Math.max(minimum, Math.min(maximum, contentHeight))}px`;
|
|
23
|
+
element.style.overflowY = contentHeight > maximum ? "auto" : "hidden";
|
|
24
|
+
element.scrollTop = scrollTop;
|
|
25
|
+
}, [ref]);
|
|
26
|
+
|
|
27
|
+
useLayoutEffect(resize, [resize, value]);
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
const element = ref.current;
|
|
30
|
+
if (!element) return;
|
|
31
|
+
let width = element.clientWidth;
|
|
32
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => {
|
|
33
|
+
const nextWidth = element.clientWidth;
|
|
34
|
+
if (nextWidth === width) return;
|
|
35
|
+
width = nextWidth;
|
|
36
|
+
resize();
|
|
37
|
+
});
|
|
38
|
+
observer?.observe(element);
|
|
39
|
+
const viewport = window.visualViewport;
|
|
40
|
+
const fonts = document.fonts;
|
|
41
|
+
const pointer = typeof window.matchMedia === "function" ? window.matchMedia("(pointer: coarse)") : null;
|
|
42
|
+
window.addEventListener("resize", resize);
|
|
43
|
+
viewport?.addEventListener("resize", resize);
|
|
44
|
+
fonts?.addEventListener("loadingdone", resize);
|
|
45
|
+
pointer?.addEventListener("change", resize);
|
|
46
|
+
return () => {
|
|
47
|
+
observer?.disconnect();
|
|
48
|
+
window.removeEventListener("resize", resize);
|
|
49
|
+
viewport?.removeEventListener("resize", resize);
|
|
50
|
+
fonts?.removeEventListener("loadingdone", resize);
|
|
51
|
+
pointer?.removeEventListener("change", resize);
|
|
52
|
+
};
|
|
53
|
+
}, [ref, resize]);
|
|
54
|
+
}
|