chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files - Prompt: attach a file on this computer by path (native picker), clearer too-large messages - Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills - Files: batch upload of small files in one extract - AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
* and tick the ones to add; or type `name@marketplace` by hand, which is
|
||||
* also where a link the server cannot read (ssh, a private repo) ends up.
|
||||
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
|
||||
* - `SkillNameField`/`splitSkillMd` — also used by the page's skill editor.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -24,8 +25,8 @@ import { api, type MarketplaceInfo } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
||||
const label = "block text-[11px] font-medium text-fg-secondary";
|
||||
const field = "input w-full";
|
||||
const label = "block text-xs text-fg-secondary";
|
||||
const hint = "text-[11px] text-fg-muted";
|
||||
|
||||
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
|
||||
@@ -155,8 +156,19 @@ function Tabs<T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
/** SKILL.md split for editing: its frontmatter `name`/`description` and the body. */
|
||||
export function splitSkillMd(text: string): { name: string; description: string; body: string } {
|
||||
const clean = text.replace(/^\uFEFF/, "");
|
||||
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(clean);
|
||||
return {
|
||||
name: frontmatterField(clean, "name"),
|
||||
description: frontmatterField(clean, "description"),
|
||||
body: (fm ? clean.slice(fm[0].length) : clean).replace(/^\s+/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
/** The name field with the folder/command it becomes. */
|
||||
function SkillNameField({
|
||||
export function SkillNameField({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
@@ -175,7 +187,7 @@ function SkillNameField({
|
||||
{t("skill.name")}
|
||||
{required ? " *" : ""}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder="release-notes"
|
||||
@@ -296,7 +308,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.description")}
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
rows={2}
|
||||
value={description}
|
||||
disabled={many}
|
||||
@@ -310,7 +322,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={title}
|
||||
disabled={many}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
@@ -452,7 +464,10 @@ export function SkillAddPanel({
|
||||
/>
|
||||
)}
|
||||
{meta}
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -489,7 +504,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.description")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
rows={2}
|
||||
value={description}
|
||||
placeholder={t("skill.descriptionHint")}
|
||||
@@ -499,7 +514,7 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("skill.content")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5 font-mono text-xs`}
|
||||
className={`${field} mt-1 font-mono text-xs`}
|
||||
rows={10}
|
||||
value={content}
|
||||
placeholder={t("skill.contentHint")}
|
||||
@@ -509,12 +524,15 @@ export function SkillAddPanel({
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
className={`${field} mt-1`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -534,7 +552,7 @@ export function SkillAddPanel({
|
||||
{mode === "existing" && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className={field}
|
||||
className="select w-full"
|
||||
aria-label={t("form.workspace")}
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
@@ -696,7 +714,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
>
|
||||
<label className={label}>
|
||||
{t("plugin.link")}
|
||||
<div className="mt-0.5 flex gap-2">
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input
|
||||
className={field}
|
||||
value={link}
|
||||
@@ -777,7 +795,15 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-sm"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
@@ -853,7 +879,10 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||
{t("form.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
@@ -902,7 +931,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
||||
<input
|
||||
type="file"
|
||||
data-testid="file-upload"
|
||||
className="mt-0.5 block w-full text-xs text-fg-secondary"
|
||||
className="mt-1 block w-full text-xs text-fg-secondary file:mr-3 file:cursor-pointer file:rounded-lg file:border file:border-border-light file:bg-surface-2 file:px-3 file:py-1.5 file:text-xs file:text-fg-primary hover:file:bg-surface-4"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @file AgentDetails.tsx
|
||||
* @description What the chat shows of a subagent, like Claude Desktop: an
|
||||
* Agent/Task call's facts (type, model, status, time, tokens, tool calls),
|
||||
* the task it was given and the report it returned — read off the call, its
|
||||
* result (`tool_result.agent`) and, for a background agent, the
|
||||
* `<task-notification>` that closed it — and, one click further, the
|
||||
* subagent's own conversation as a small chat right there in the step, every
|
||||
* step of it. The chat's owner (ConversationView) supplies the links through
|
||||
* `AgentContext`: which agent transcript a call launched, the notifications
|
||||
* seen, and how to render one agent's conversation (a nested
|
||||
* ConversationView — passed in, so this module never imports it back).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, ChevronRight, CircleSlash, Loader2, XCircle } from "lucide-react";
|
||||
import type { TranscriptContent, TranscriptMessage } from "../../lib/types";
|
||||
import { fmt, formatModelName, formatMs } from "../../lib/format";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
|
||||
export type AgentStatus = "running" | "done" | "error" | "stopped";
|
||||
|
||||
/** One subagent as the chat knows it from the call that launched it. */
|
||||
export interface AgentRef {
|
||||
/** The Agent/Task `tool_use` id. */
|
||||
toolUseId: string;
|
||||
/** Its own transcript's `agent_id`; null while nothing names it yet. */
|
||||
agentId: string | null;
|
||||
type: string;
|
||||
description: string;
|
||||
model: string | null;
|
||||
status: AgentStatus;
|
||||
background: boolean;
|
||||
prompt: string | null;
|
||||
report: string | null;
|
||||
durationMs: number | null;
|
||||
tokens: number | null;
|
||||
toolUses: number | null;
|
||||
}
|
||||
|
||||
/** What a `<task-notification>` said about a background agent. */
|
||||
export interface AgentNotice {
|
||||
status: string;
|
||||
result: string | null;
|
||||
durationMs: number | null;
|
||||
tokens: number | null;
|
||||
toolUses: number | null;
|
||||
}
|
||||
|
||||
export interface AgentLinks {
|
||||
/** tool_use id → agent transcript id, from the session's transcript list. */
|
||||
ids: Map<string, string>;
|
||||
/** tool_use id → the notification that closed that background agent. */
|
||||
notices: Map<string, AgentNotice>;
|
||||
/** The session runs in a pane: a call with no result yet still works. */
|
||||
live: boolean;
|
||||
/** One agent's own conversation, as a chat that fills its box. */
|
||||
render?: (agent: AgentRef) => React.ReactNode;
|
||||
}
|
||||
|
||||
export const AgentContext = createContext<AgentLinks>({
|
||||
ids: new Map(),
|
||||
notices: new Map(),
|
||||
live: false,
|
||||
});
|
||||
|
||||
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
||||
export const isAgentCall = (b: TranscriptContent) =>
|
||||
b.type === "tool_use" && AGENT_TOOLS.has(b.name ?? "");
|
||||
|
||||
// The result names the agent's own transcript (`agent-<id>.jsonl`).
|
||||
const AGENT_ID = /agentId:\s*([\w-]+)/;
|
||||
const USAGE = /<usage>([\s\S]*?)<\/usage>/;
|
||||
|
||||
const tag = (text: string, name: string) =>
|
||||
text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim() ?? null;
|
||||
|
||||
/** The time, tokens and tool calls of a `<usage>` block, in either form
|
||||
* Claude Code writes it: `<total_tokens>N</…>` tags or `total_tokens: N`. */
|
||||
export function parseUsage(text: string): Pick<AgentNotice, "durationMs" | "tokens" | "toolUses"> {
|
||||
const body = text.match(USAGE)?.[1] ?? "";
|
||||
const num = (...names: string[]) => {
|
||||
for (const n of names) {
|
||||
const v = body.match(new RegExp(`${n}(?:>|:\\s*)(\\d+)`))?.[1];
|
||||
if (v) return Number(v);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
durationMs: num("duration_ms"),
|
||||
tokens: num("subagent_tokens", "total_tokens"),
|
||||
toolUses: num("tool_uses"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Each `<task-notification>` in the transcript, by the call it closes. */
|
||||
export function agentNotices(messages: TranscriptMessage[]): Map<string, AgentNotice> {
|
||||
const out = new Map<string, AgentNotice>();
|
||||
for (const msg of messages) {
|
||||
if (msg.type !== "user") continue;
|
||||
for (const c of msg.content) {
|
||||
if (c.type !== "text" || !c.text?.includes("<task-notification>")) continue;
|
||||
for (const part of c.text.split("<task-notification>").slice(1)) {
|
||||
const id = tag(part, "tool-use-id");
|
||||
if (!id) continue;
|
||||
out.set(id, {
|
||||
status: tag(part, "status") ?? "completed",
|
||||
result: tag(part, "result"),
|
||||
...parseUsage(part),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A report without the footer Claude Code appends for the orchestrator. */
|
||||
function stripFooter(text: string): string {
|
||||
return text
|
||||
.replace(USAGE, "")
|
||||
.replace(/^agentId:.*$/m, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Everything the chat can say about the subagent `block` launched. */
|
||||
export function agentRef(
|
||||
block: TranscriptContent,
|
||||
result: TranscriptContent | null | undefined,
|
||||
links: AgentLinks
|
||||
): AgentRef {
|
||||
const raw = block.input as Record<string, unknown> | undefined;
|
||||
const input = raw && !("_truncated" in raw) ? raw : {};
|
||||
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||||
const id = block.id ?? "";
|
||||
const facts = result?.agent;
|
||||
const output = result?.output ?? "";
|
||||
const background = facts?.status === "async_launched" || output.includes("Async agent launched");
|
||||
const notice = background ? links.notices.get(id) : undefined;
|
||||
const usage = !background && output ? parseUsage(output) : null;
|
||||
|
||||
let status: AgentStatus;
|
||||
if (result?.is_error) status = "error";
|
||||
else if (!result || (background && !notice)) status = links.live ? "running" : "stopped";
|
||||
else if (notice && notice.status !== "completed")
|
||||
status = notice.status === "failed" ? "error" : "stopped";
|
||||
else status = "done";
|
||||
|
||||
return {
|
||||
toolUseId: id,
|
||||
agentId: facts?.id ?? output.match(AGENT_ID)?.[1] ?? links.ids.get(id) ?? null,
|
||||
type: str(input.subagent_type) ?? "Agent",
|
||||
description: str(input.description) ?? "",
|
||||
model: facts?.model ?? str(input.model),
|
||||
status,
|
||||
background,
|
||||
prompt: str(input.prompt),
|
||||
report: background ? (notice?.result ?? null) : output ? stripFooter(output) || null : null,
|
||||
durationMs: facts?.duration_ms ?? notice?.durationMs ?? usage?.durationMs ?? null,
|
||||
tokens: facts?.tokens ?? notice?.tokens ?? usage?.tokens ?? null,
|
||||
toolUses: facts?.tool_uses ?? notice?.toolUses ?? usage?.toolUses ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<AgentStatus, React.ReactNode> = {
|
||||
running: <Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />,
|
||||
done: <CheckCircle2 className="h-3.5 w-3.5 flex-shrink-0 text-status-success" />,
|
||||
error: <XCircle className="h-3.5 w-3.5 flex-shrink-0 text-status-danger" />,
|
||||
stopped: <CircleSlash className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />,
|
||||
};
|
||||
|
||||
/** One muted line: its status as an icon, then type · model · time ·
|
||||
* tokens · tool calls, as far as they are known. */
|
||||
export function AgentFacts({ agent }: { agent: AgentRef }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const parts = [
|
||||
agent.type,
|
||||
agent.model && formatModelName(agent.model),
|
||||
agent.background && t("conversation.agent.background"),
|
||||
agent.durationMs != null && formatMs(agent.durationMs),
|
||||
agent.tokens != null && t("conversation.agentContext", { tokens: fmt(agent.tokens) }),
|
||||
agent.toolUses != null && t("conversation.agent.toolUses", { count: agent.toolUses }),
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div
|
||||
data-testid="agent-facts"
|
||||
title={t(`conversation.agent.status.${agent.status}`)}
|
||||
className="flex min-w-0 items-center gap-1.5 text-xs text-fg-muted"
|
||||
>
|
||||
{STATUS_ICON[agent.status]}
|
||||
<span className="sr-only">{t(`conversation.agent.status.${agent.status}`)}</span>
|
||||
<span className="truncate">{parts.join(" · ")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const linkClass =
|
||||
"inline-flex items-center gap-0.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary";
|
||||
|
||||
/** The subagent's own conversation, a thread hung under its step by a line
|
||||
* on the left — as tall as it is, up to 24rem, then it scrolls. */
|
||||
export function AgentChatBox({ agent }: { agent: AgentRef }) {
|
||||
const links = useContext(AgentContext);
|
||||
return (
|
||||
<div data-testid="agent-chat" className="mt-2 border-l-2 border-border pl-3">
|
||||
{links.render?.(agent)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An Agent/Task step opened in the chat: one line of facts, its report as
|
||||
* prose, and "View conversation ›", which puts the agent's own thread (its
|
||||
* task, every step, its reply) in the report's place. With no transcript
|
||||
* to show, the task it was given opens on its own instead. */
|
||||
export function AgentStepBody({
|
||||
block,
|
||||
result,
|
||||
}: {
|
||||
block: TranscriptContent;
|
||||
result: TranscriptContent | null;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const links = useContext(AgentContext);
|
||||
const agent = agentRef(block, result, links);
|
||||
const canOpen = !!agent.agentId && !!links.render;
|
||||
const [thread, setThread] = useState(false);
|
||||
const [task, setTask] = useState(false);
|
||||
return (
|
||||
<div data-testid="agent-step" className="space-y-1.5">
|
||||
<AgentFacts agent={agent} />
|
||||
{thread ? (
|
||||
<AgentChatBox agent={agent} />
|
||||
) : (
|
||||
<>
|
||||
{!canOpen && agent.prompt && task && (
|
||||
<div className="max-h-60 overflow-auto border-l-2 border-border pl-3 text-fg-secondary">
|
||||
<MarkdownContent text={agent.prompt} dense />
|
||||
</div>
|
||||
)}
|
||||
{agent.report && (
|
||||
<div
|
||||
className={`max-h-60 overflow-auto ${agent.status === "error" ? "text-status-danger" : "text-fg-secondary"}`}
|
||||
>
|
||||
<MarkdownContent text={agent.report} dense />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="agent-open"
|
||||
aria-expanded={thread}
|
||||
onClick={() => setThread(!thread)}
|
||||
className={linkClass}
|
||||
>
|
||||
{t(thread ? "conversation.agent.hide" : "conversation.agent.open")}
|
||||
<ChevronRight className={`h-3 w-3 transition-transform ${thread ? "-rotate-90" : ""}`} />
|
||||
</button>
|
||||
) : (
|
||||
agent.prompt && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={task}
|
||||
onClick={() => setTask(!task)}
|
||||
className={linkClass}
|
||||
>
|
||||
{t("conversation.agent.prompt")}
|
||||
<ChevronRight className={`h-3 w-3 transition-transform ${task ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,7 @@ import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList, promptTokens } from "./MessageList";
|
||||
import { agentNotices, type AgentLinks, type AgentRef } from "./AgentDetails";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
@@ -102,6 +103,9 @@ interface ConversationViewProps {
|
||||
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
|
||||
* page-height cap — the parent sizes it and puts a composer under it. */
|
||||
fill?: boolean;
|
||||
/** A subagent's conversation inside its step: no toolbar, as tall as its
|
||||
* content up to 24rem, then it scrolls. */
|
||||
compact?: boolean;
|
||||
/** Opens a file named in a turn's "Edited N files" card. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** The session runs in a pane: show the step in progress. */
|
||||
@@ -127,6 +131,7 @@ function ConversationViewPane({
|
||||
sessionId,
|
||||
initialTranscriptId,
|
||||
fill = false,
|
||||
compact = false,
|
||||
onOpenFile,
|
||||
live = false,
|
||||
follow = 0,
|
||||
@@ -186,6 +191,27 @@ function ConversationViewPane({
|
||||
// Refresh-button spinner state - separate from initial `loading` so the
|
||||
// existing skeleton doesn't blink during a manual refresh.
|
||||
|
||||
// A subagent's own conversation opens inside its step as a small chat: this
|
||||
// same view, on that agent's transcript.
|
||||
const renderAgent = useCallback(
|
||||
(a: AgentRef) => (
|
||||
<ConversationView
|
||||
key={a.agentId}
|
||||
sessionId={sessionId}
|
||||
initialTranscriptId={a.agentId}
|
||||
compact
|
||||
live={live && a.status === "running"}
|
||||
ended={a.status !== "running"}
|
||||
/>
|
||||
),
|
||||
[sessionId, live]
|
||||
);
|
||||
const agentLinks = useMemo<AgentLinks>(() => {
|
||||
const ids = new Map<string, string>();
|
||||
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
|
||||
return { ids, notices: agentNotices(messages), live, render: renderAgent };
|
||||
}, [transcripts, messages, live, renderAgent]);
|
||||
|
||||
// Load available transcript list (also rescanned on a short interval so
|
||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
||||
useEffect(() => {
|
||||
@@ -518,7 +544,7 @@ function ConversationViewPane({
|
||||
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
|
||||
{/* Toolbar - always rendered after the initial load so users can
|
||||
refresh even when no messages have streamed yet. */}
|
||||
{!loading && !fill && (
|
||||
{!loading && !fill && !compact && (
|
||||
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
||||
{transcripts.length > 1 && (
|
||||
<div className="relative">
|
||||
@@ -566,7 +592,13 @@ function ConversationViewPane({
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
style={
|
||||
compact
|
||||
? { maxHeight: "24rem" }
|
||||
: fill
|
||||
? { minHeight: 0 }
|
||||
: { maxHeight: "calc(100vh - 320px)", minHeight: 200 }
|
||||
}
|
||||
>
|
||||
{/* A chat keeps its first turn clear of the pane's top-right controls
|
||||
(the outline's file button floats there). */}
|
||||
@@ -589,7 +621,9 @@ function ConversationViewPane({
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
<div
|
||||
className={`flex items-center justify-center text-fg-muted text-sm ${compact ? "py-3" : "py-12"}`}
|
||||
>
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 && notStarted ? (
|
||||
@@ -624,6 +658,8 @@ function ConversationViewPane({
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
stopped={stopped}
|
||||
agentLinks={agentLinks}
|
||||
compact={compact}
|
||||
sessionId={
|
||||
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||
}
|
||||
@@ -653,7 +689,7 @@ function ConversationViewPane({
|
||||
)}
|
||||
|
||||
{/* New messages indicator */}
|
||||
{showNewMsg && !fill && (
|
||||
{showNewMsg && !fill && !compact && (
|
||||
<button
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs. A file path — absolute, or relative with a folder and
|
||||
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
|
||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||
* inline code or as a markdown link's target — opens through `OpenFileContext`
|
||||
* when the page provides one (the page resolves a relative one against its own
|
||||
@@ -272,8 +272,14 @@ const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?
|
||||
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
|
||||
const REL_PATH_RE =
|
||||
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
|
||||
// A Windows absolute path (`E:\notes\a.xlsx`, `C:/x/y.ts:9`): a drive letter and one segment at least.
|
||||
const WIN_PATH_RE =
|
||||
/^[A-Za-z]:[\\/](?:[^\s\\/<>()'"`,;:|*?]+[\\/])*[^\s\\/<>()'"`,;:|*?]*[^\s\\/<>()'"`,;:|*?.!](?::\d+(?::\d+)?)?/;
|
||||
// In inline code the whole text is the path, so a Windows one may hold spaces.
|
||||
const WIN_CODE_PATH_RE = /^[A-Za-z]:[\\/][^<>"|?*\n]*[^\s<>"|?*\\/.]$/;
|
||||
/** A path at the start of `text`, absolute or relative, or null. */
|
||||
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
const matchPath = (text: string) =>
|
||||
PATH_RE.exec(text)?.[0] ?? WIN_PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
|
||||
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
|
||||
// A markdown link target that is a page, not a file.
|
||||
@@ -393,7 +399,13 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
{codeM[1]}
|
||||
</code>
|
||||
);
|
||||
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
|
||||
push(
|
||||
matchPath(codeM[1]!) === codeM[1] || WIN_CODE_PATH_RE.test(codeM[1]!) ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
)
|
||||
);
|
||||
i += codeM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file AgentDetails.test.tsx
|
||||
* @description Verifies what the chat shows of a subagent: an Agent step's
|
||||
* facts, task and report (from its result, or the task-notification that
|
||||
* closed a background one), and that "View conversation" opens that
|
||||
* agent's own conversation in place, rendered by the chat's owner.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MessageList } from "../MessageList";
|
||||
import { agentNotices, agentRef, parseUsage, type AgentLinks } from "../AgentDetails";
|
||||
import type { TranscriptContent, TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const at = new Date().toISOString();
|
||||
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
|
||||
|
||||
const call: TranscriptContent = {
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Agent",
|
||||
input: { description: "Map the hooks", subagent_type: "Explore", prompt: "Read **server/**" },
|
||||
};
|
||||
const links = (p: Partial<AgentLinks> = {}): AgentLinks => ({
|
||||
ids: new Map(),
|
||||
notices: new Map(),
|
||||
live: false,
|
||||
...p,
|
||||
});
|
||||
|
||||
describe("agentRef", () => {
|
||||
it("reads a foreground agent's facts and report, without the orchestrator's footer", () => {
|
||||
const a = agentRef(
|
||||
call,
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "The hooks live in server/routes/hooks.js.\n\nagentId: a1b2 (for resuming)",
|
||||
agent: {
|
||||
id: "a1b2",
|
||||
model: "claude-opus-5-5",
|
||||
status: "completed",
|
||||
duration_ms: 65_000,
|
||||
tokens: 12_000,
|
||||
tool_uses: 7,
|
||||
},
|
||||
},
|
||||
links()
|
||||
);
|
||||
expect(a).toMatchObject({
|
||||
agentId: "a1b2",
|
||||
type: "Explore",
|
||||
status: "done",
|
||||
background: false,
|
||||
prompt: "Read **server/**",
|
||||
report: "The hooks live in server/routes/hooks.js.",
|
||||
durationMs: 65_000,
|
||||
tokens: 12_000,
|
||||
toolUses: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes a background agent with its notification's report and usage", () => {
|
||||
const notice = m({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<task-notification>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n<result>All done</result>\n<usage><subagent_tokens>88007</subagent_tokens><tool_uses>31</tool_uses><duration_ms>132350</duration_ms></usage>\n</task-notification>",
|
||||
},
|
||||
],
|
||||
});
|
||||
const launched: TranscriptContent = {
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "Async agent launched successfully.\nagentId: a9b4",
|
||||
};
|
||||
const running = agentRef(call, launched, links({ live: true }));
|
||||
expect(running).toMatchObject({ status: "running", background: true, report: null });
|
||||
const done = agentRef(call, launched, links({ notices: agentNotices([notice]) }));
|
||||
expect(done).toMatchObject({
|
||||
agentId: "a9b4",
|
||||
status: "done",
|
||||
report: "All done",
|
||||
tokens: 88007,
|
||||
toolUses: 31,
|
||||
durationMs: 132350,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds the transcript from the session's list when the result names none", () => {
|
||||
const a = agentRef(call, null, links({ ids: new Map([["t1", "c3d4"]]) }));
|
||||
expect(a).toMatchObject({ agentId: "c3d4", status: "stopped" });
|
||||
});
|
||||
|
||||
it("parses the line form of a usage block", () => {
|
||||
expect(parseUsage("<usage>total_tokens: 500\ntool_uses: 3\nduration_ms: 900</usage>")).toEqual({
|
||||
tokens: 500,
|
||||
toolUses: 3,
|
||||
durationMs: 900,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("an Agent step in the chat", () => {
|
||||
it("shows the agent's facts and report, and opens its conversation in place", () => {
|
||||
const render_ = vi.fn((a: { agentId: string | null }) => <p>chat of {a.agentId}</p>);
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
agentLinks={links({ render: render_ })}
|
||||
messages={[
|
||||
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
|
||||
m({ type: "assistant", sender: "assistant", stop_reason: "tool_use", content: [call] }),
|
||||
m({
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "t1",
|
||||
output: "Found **3** hooks",
|
||||
agent: {
|
||||
id: "a1b2",
|
||||
model: null,
|
||||
status: "completed",
|
||||
duration_ms: null,
|
||||
tokens: null,
|
||||
tool_uses: 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /ran an agent/i }));
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("Explore");
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("done");
|
||||
expect(screen.getByTestId("agent-facts").textContent).toContain("4 tool calls");
|
||||
expect(screen.getByTestId("agent-step").textContent).toContain("Found 3 hooks");
|
||||
expect(screen.queryByTestId("agent-chat")).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("agent-open"));
|
||||
expect(screen.getByTestId("agent-chat").textContent).toBe("chat of a1b2");
|
||||
// The thread takes the report's place.
|
||||
expect(screen.getByTestId("agent-step").textContent).not.toContain("Found 3 hooks");
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,20 @@ describe("file links", () => {
|
||||
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
|
||||
});
|
||||
|
||||
it("opens a Windows path, bare or in inline code", () => {
|
||||
const open = withOpen(
|
||||
"File: `E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx`. Also C:/src/a.ts:9, and `D:\\My Docs\\b.md`"
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "C:/src/a.ts:9" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "D:\\My Docs\\b.md" }));
|
||||
expect(open.mock.calls).toEqual([
|
||||
["E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx"],
|
||||
["C:/src/a.ts"],
|
||||
["D:\\My Docs\\b.md"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a markdown link to a file instead of navigating", () => {
|
||||
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
|
||||
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
|
||||
|
||||
@@ -138,7 +138,28 @@ describe("MessageList — sender attribution", () => {
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/↓ 123/)).toBeTruthy();
|
||||
// In the hover bar's tooltip, beside the model and time.
|
||||
expect(screen.getByTestId("chat-actions").getAttribute("title")).toMatch(/↓ 123/);
|
||||
});
|
||||
|
||||
it("shows a local command as one line with its output, never its caveat", () => {
|
||||
const line = (text: string, sender: "user" | "system" = "user") =>
|
||||
msg({ type: "user", sender, content: [{ type: "text", text }] });
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
line("<local-command-caveat>Caveat: the command below…</local-command-caveat>", "system"),
|
||||
line("<command-name>/model</command-name><command-args>opus</command-args>"),
|
||||
line("<local-command-stdout>Set model to Opus</local-command-stdout>"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId("chat-command");
|
||||
expect(row).toHaveTextContent("/model opus");
|
||||
expect(row).toHaveTextContent("Set model to Opus");
|
||||
expect(screen.queryByText(/Caveat/)).toBeNull();
|
||||
expect(screen.queryByTestId("chat-user")).toBeNull();
|
||||
});
|
||||
|
||||
it("ends a live chat with the step still running", () => {
|
||||
@@ -168,7 +189,9 @@ describe("MessageList — sender attribution", () => {
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Run the full client suite");
|
||||
// Like the terminal: the turn's time since the prompt, and its output.
|
||||
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
|
||||
// (the output shows on hover).
|
||||
expect(row).toHaveTextContent(/1m \ds/);
|
||||
expect(row).toHaveTextContent(/↑ 3\.5K tokens/);
|
||||
// Its result arrived: nothing is running any more.
|
||||
rerender(
|
||||
<MessageList
|
||||
@@ -341,6 +364,14 @@ describe("MessageList — sender attribution", () => {
|
||||
content: [{ type: "text", text: "[Request interrupted by user]" }],
|
||||
});
|
||||
expect(state([prompt, call, result, stopped])).toBeNull();
|
||||
// A local command (`/clear`) is answered by the CLI itself: no turn.
|
||||
const local = (text: string) =>
|
||||
msg({ type: "user", sender: "user", timestamp: at, content: [{ type: "text", text }] });
|
||||
const clear = local("<command-name>/clear</command-name>");
|
||||
const out = local("<local-command-stdout></local-command-stdout>");
|
||||
expect(state([clear, out])).toBeNull();
|
||||
// A prompt command (a skill) is still answered by the model.
|
||||
expect(state([clear])).toEqual(thinking);
|
||||
// Silent for 15 minutes with nothing running: the turn died.
|
||||
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -95,6 +95,44 @@ import { CloneHereDialog } from "./CloneHereDialog";
|
||||
|
||||
/** One upload batch at most (the server caps one file the same). */
|
||||
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
||||
/** Files up to this size travel together, many per request (`uploadBatch`):
|
||||
* their cost is per FILE (a request, a path walk, a sudo), not per byte. */
|
||||
const SMALL_FILE = 4 * 1024 * 1024;
|
||||
const PACK_BYTES = 32 * 1024 * 1024;
|
||||
const PACK_FILES = 1000;
|
||||
/** Keeps a pack's path list well under the server's 1 MB JSON body (upload-check). */
|
||||
const PACK_NAME_BYTES = 400 * 1024;
|
||||
|
||||
/** Small files in packs (each ≥ 2 files) and the rest, one by one. */
|
||||
export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
|
||||
const packs: T[][] = [];
|
||||
const singles: T[] = [];
|
||||
let cur: T[] = [];
|
||||
let bytes = 0;
|
||||
let names = 0;
|
||||
for (const f of files) {
|
||||
if (f.file.size > SMALL_FILE) {
|
||||
singles.push(f);
|
||||
continue;
|
||||
}
|
||||
const n = f.rel.length * 3 + 64;
|
||||
if (
|
||||
cur.length &&
|
||||
(cur.length >= PACK_FILES || bytes + f.file.size > PACK_BYTES || names + n > PACK_NAME_BYTES)
|
||||
) {
|
||||
packs.push(cur);
|
||||
cur = [];
|
||||
bytes = 0;
|
||||
names = 0;
|
||||
}
|
||||
cur.push(f);
|
||||
bytes += f.file.size;
|
||||
names += n;
|
||||
}
|
||||
if (cur.length) packs.push(cur);
|
||||
for (const p of packs.filter((p) => p.length < 2)) singles.push(...p);
|
||||
return { packs: packs.filter((p) => p.length >= 2), singles };
|
||||
}
|
||||
const MIME = "application/x-agenthub-fx";
|
||||
const VIEW_KEY = "agenthub.files.view";
|
||||
const HIDDEN_KEY = "agenthub.files.hidden";
|
||||
@@ -327,7 +365,11 @@ export function FileBrowser({
|
||||
? (lanes.find((l) => l.id === target)?.cwd ?? null)
|
||||
: null;
|
||||
const cloneParent =
|
||||
cloneRoot && view === "files" ? (path ? `${cloneRoot.replace(/\/+$/, "")}/${path}` : cloneRoot) : null;
|
||||
cloneRoot && view === "files"
|
||||
? path
|
||||
? `${cloneRoot.replace(/\/+$/, "")}/${path}`
|
||||
: cloneRoot
|
||||
: null;
|
||||
|
||||
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
|
||||
const go = (p: string, tgt: FsPlace = target) => {
|
||||
@@ -556,32 +598,95 @@ export function FileBrowser({
|
||||
}
|
||||
setBusy(true);
|
||||
let sent = 0;
|
||||
let done = 0;
|
||||
let saved = 0;
|
||||
let forAll: FsConflict | null = null;
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const { file, rel } = files[i]!;
|
||||
const dest = join(dir, rel);
|
||||
setProgress({ name: rel, done: i, total: files.length, sent, bytes });
|
||||
const send = (c: FsConflict) =>
|
||||
api.files.upload(target, dest, file, c, (n) =>
|
||||
setProgress({ name: rel, done: i, total: files.length, sent: sent + n, bytes })
|
||||
);
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
const a = await askConflict([rel], files.length > 1);
|
||||
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
if (a.all) forAll = a.choice;
|
||||
return send(a.choice);
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL") break;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
}
|
||||
sent += file.size;
|
||||
// false once the server says a batch cannot be written here (an old helper).
|
||||
let batchable = true;
|
||||
const show = (name: string, extra = 0) =>
|
||||
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
|
||||
/** One file; false = the user cancelled. */
|
||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||
const dest = join(dir, rel);
|
||||
show(rel);
|
||||
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
|
||||
try {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
const a = await askConflict([rel], files.length > 1);
|
||||
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
if (a.all) forAll = a.choice;
|
||||
return send(a.choice);
|
||||
});
|
||||
if (!r.skipped) saved++;
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL") return false;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
}
|
||||
sent += file.size;
|
||||
done++;
|
||||
return true;
|
||||
};
|
||||
/** Many small files in one request: "ok", "cancel", or "fallback" (send them one by one). */
|
||||
const pack = async (b: { file: File; rel: string }[]) => {
|
||||
const label = `${b[0]!.rel} (+${b.length - 1})`;
|
||||
const size = b.reduce((n, f) => n + f.file.size, 0);
|
||||
show(label);
|
||||
// Settled BEFORE the bytes go: a 409 after a 32 MB body would waste it.
|
||||
const choose = async (taken: string[]) => {
|
||||
if (forAll) return forAll;
|
||||
if (!taken.length) return "fail" as const;
|
||||
const a = await askConflict(taken, files.length > 1);
|
||||
if (a.all && a.choice) forAll = a.choice;
|
||||
return a.choice;
|
||||
};
|
||||
try {
|
||||
const c = await api.files.uploadCheck(
|
||||
target,
|
||||
dir,
|
||||
b.map((f) => f.rel)
|
||||
);
|
||||
if (!c.batch) {
|
||||
batchable = false;
|
||||
return "fallback";
|
||||
}
|
||||
let policy = await choose(c.taken);
|
||||
if (!policy) return "cancel";
|
||||
const send = (p: FsConflict) =>
|
||||
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
|
||||
const r = await send(policy).catch(async (e) => {
|
||||
// Taken meanwhile: ask about those, then once more.
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
policy = await choose((e as { names?: string[] }).names ?? []);
|
||||
if (!policy) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||
return send(policy);
|
||||
});
|
||||
saved += r.saved;
|
||||
} catch (e) {
|
||||
const code = errCode(e);
|
||||
if (code === "ECANCEL") return "cancel";
|
||||
// Refused before a byte was written (a bad name, an old helper…):
|
||||
// each file then gets its own answer.
|
||||
if (code === "EUPGRADE") batchable = false;
|
||||
if (code && /^E(UPGRADE|BADNAME|BADPATH|SECRET|BADBODY|TOOBIG|EXISTS)$/.test(code)) {
|
||||
return "fallback";
|
||||
}
|
||||
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
||||
}
|
||||
sent += size;
|
||||
done += b.length;
|
||||
return "ok";
|
||||
};
|
||||
try {
|
||||
const { packs, singles } = packUploads(files);
|
||||
let go = true;
|
||||
for (const b of packs) {
|
||||
const r = batchable ? await pack(b) : "fallback";
|
||||
if (r === "cancel") go = false;
|
||||
if (r === "fallback") for (const f of b) if (go && !(await one(f))) go = false;
|
||||
if (!go) break;
|
||||
}
|
||||
for (const f of singles) if (!go || !(await one(f))) break;
|
||||
} finally {
|
||||
setProgress(null);
|
||||
setBusy(false);
|
||||
@@ -1488,7 +1593,9 @@ export function FileBrowser({
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
|
||||
{cloneParent && (
|
||||
<MenuItem onSelect={close(() => setCloning(true))}>{t("fx.cloneRepo")}</MenuItem>
|
||||
<MenuItem onSelect={close(() => setCloning(true))}>
|
||||
{t("fx.cloneRepo")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={close(() => fileInput.current?.click())}>
|
||||
|
||||
@@ -45,6 +45,8 @@ vi.mock("../../../lib/api", () => ({
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
uploadBatch: vi.fn(),
|
||||
uploadCheck: vi.fn(),
|
||||
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
||||
},
|
||||
},
|
||||
@@ -196,6 +198,41 @@ describe("FileExplorer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends many small files as one batch, asking once about every taken name", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
|
||||
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const files = ["a.txt", "b.txt", "c.txt"].map((n) => new File(["hi"], n));
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.uploadBatch).toHaveBeenCalledWith(
|
||||
1,
|
||||
"",
|
||||
files.map((file) => ({ file, rel: file.name })),
|
||||
"replace",
|
||||
expect.any(Function)
|
||||
)
|
||||
);
|
||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||
expect(api.files.upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to one request per file when the server cannot take a batch", async () => {
|
||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
|
||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
|
||||
expect(api.files.uploadBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a batch over 500 MB before sending anything", async () => {
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @file ChatAddMenu.tsx
|
||||
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
|
||||
* files (the prompt box's own picker), what is already there to attach —
|
||||
* files (the prompt box's own picker), Choose a file on this computer (a
|
||||
* browser on the dashboard's own machine only: the OS's own picker,
|
||||
* `POST /api/lanes/pick-path`, attaches the file by its path — any size,
|
||||
* nothing uploaded), what is already there to attach —
|
||||
* Project files (a search of the box's folder, folders too:
|
||||
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
|
||||
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
History,
|
||||
Paperclip,
|
||||
Plug,
|
||||
@@ -29,7 +33,14 @@ import {
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
|
||||
import {
|
||||
isLocalBrowser,
|
||||
localAttachment,
|
||||
projectAttachment,
|
||||
recentUploads,
|
||||
type Attachment,
|
||||
} from "../../lib/promptAttach";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
interface Entry {
|
||||
name: string;
|
||||
@@ -244,6 +255,17 @@ export function ChatAddMenu({
|
||||
close();
|
||||
onAttach([item]);
|
||||
};
|
||||
const pickLocal = async () => {
|
||||
close();
|
||||
// The dialog opens on the desktop, sometimes behind this window.
|
||||
toast.info(t("chat.pickLocalOpen"));
|
||||
try {
|
||||
const r = await api.lanes.pickPath("file", cwd || undefined);
|
||||
if (r.path) onAttach([localAttachment(r.path)]);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
const manage = (tab: "skills" | "plugins") => {
|
||||
close();
|
||||
navigate(`/cc-config?tab=${tab}`);
|
||||
@@ -291,6 +313,16 @@ export function ChatAddMenu({
|
||||
onUpload();
|
||||
}}
|
||||
/>
|
||||
{isLocalBrowser() && (
|
||||
<Row
|
||||
icon={<HardDrive className="h-4 w-4" />}
|
||||
label={t("chat.pickLocal")}
|
||||
title={t("chat.pickLocalHint")}
|
||||
testId="chat-add-pick-local"
|
||||
onHover={() => setSub(null)}
|
||||
onClick={() => void pickLocal()}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
label={t("chat.projectFiles")}
|
||||
|
||||
@@ -403,7 +403,11 @@ export const PaneChat = memo(function PaneChat({
|
||||
// status filter ignored: check the status here.
|
||||
.then((r) => {
|
||||
const main = r.agents.filter((a) => a.type === "main");
|
||||
setWorking(main.some((a) => a.status === "working"));
|
||||
// A fresh start, resume or `/clear` leaves main "working" at an
|
||||
// empty prompt (SessionStart's flag) until the first prompt.
|
||||
setWorking(
|
||||
main.some((a) => a.status === "working" && agentAwaitingReason(a) !== "session_start")
|
||||
);
|
||||
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
||||
})
|
||||
.catch(() => {}),
|
||||
@@ -561,7 +565,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{menu && (
|
||||
<PaneMenuCard
|
||||
// A new question starts with its field closed.
|
||||
@@ -578,7 +582,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
{/* In the card's place while the CLI asks: hidden, never unmounted,
|
||||
so a draft or an upload in flight survives the question. */}
|
||||
<div
|
||||
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
|
||||
className={`rounded-[1.25rem] border border-border bg-surface-1 p-2.5 shadow-[0_2px_12px_-4px_rgb(0_0_0/0.12)] transition-[border-color,box-shadow] focus-within:border-border-light focus-within:shadow-[0_4px_20px_-6px_rgb(0_0_0/0.2)] ${menu ? "hidden" : ""}`}
|
||||
>
|
||||
<PromptEditor
|
||||
bare
|
||||
@@ -633,8 +637,13 @@ export const PaneChat = memo(function PaneChat({
|
||||
}}
|
||||
aria-label={t("chat.stop")}
|
||||
title={t("chat.stop")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
|
||||
>
|
||||
{/* A turning ring: the turn is still going. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-accent"
|
||||
/>
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
||||
* as an `@path` chip — so is a local file a browser on the dashboard's
|
||||
* own machine drops or pastes with its `file://` path, never uploaded —
|
||||
* a `bare` one takes a drop anywhere on its box;
|
||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||
* the textarea (`lib/promptTokens.ts`);
|
||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||
@@ -71,6 +73,10 @@ import {
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
isLocalBrowser,
|
||||
localAttachment,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
@@ -229,7 +235,10 @@ export function promptForPane(
|
||||
}
|
||||
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
|
||||
return {
|
||||
text: promptWithImages(text, images.filter((i) => !isImage(i))),
|
||||
text: promptWithImages(
|
||||
text,
|
||||
images.filter((i) => !isImage(i))
|
||||
),
|
||||
files: images.filter(isImage).map((i) => i.path),
|
||||
};
|
||||
}
|
||||
@@ -399,7 +408,11 @@ export function PromptEditor({
|
||||
// A pasted or dropped file is saved on the server's machine (the same
|
||||
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
|
||||
// its path is added to the prompt only when it is sent (promptWithImages).
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | "tooBig" | "tooBigLocal" | null>(
|
||||
null
|
||||
);
|
||||
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
|
||||
const [limitMb, setLimitMb] = useState(0);
|
||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||
const uploading = upload === "uploading";
|
||||
@@ -410,20 +423,64 @@ export function PromptEditor({
|
||||
const picked = Array.from(list ?? []);
|
||||
if (picked.length === 0) return false;
|
||||
setUpload("uploading");
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
|
||||
.then((saved) => {
|
||||
setUpload(null);
|
||||
const added = picked.map((f, i) => ({
|
||||
path: saved[i]!.path,
|
||||
// Each file on its own: one refused file does not cost the others.
|
||||
void Promise.allSettled(picked.map((f) => api.run.dropFile(f, lane))).then((results) => {
|
||||
const added: PromptImage[] = [];
|
||||
const errors: unknown[] = [];
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === "rejected") return void errors.push(r.reason);
|
||||
const f = picked[i]!;
|
||||
added.push({
|
||||
path: r.value.path,
|
||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||
name: f.name,
|
||||
}));
|
||||
});
|
||||
});
|
||||
if (added.length) {
|
||||
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
|
||||
keepError.current = errors.length > 0; // these came with the error
|
||||
onImagesChange((prev) => [...prev, ...added]);
|
||||
})
|
||||
.catch(() => setUpload("failed"));
|
||||
}
|
||||
// Over the server's limit (ETOOBIG): refused before the body is sent.
|
||||
const tooBig = errors
|
||||
.map((e) => e as { code?: string; maxBytes?: number } | null)
|
||||
.find((e) => e?.code === "ETOOBIG");
|
||||
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||
setUpload(
|
||||
!errors.length ? null : !tooBig ? "failed" : isLocalBrowser() ? "tooBigLocal" : "tooBig"
|
||||
);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
/** Dropped or pasted files: by their own paths when the browser is on the
|
||||
* dashboard's machine and the server finds the same files there (a file
|
||||
* manager hands over `file://` paths beside the bytes), else uploaded. */
|
||||
const attachDropped = (
|
||||
dt: DataTransfer | null | undefined,
|
||||
list: FileList | null | undefined
|
||||
) => {
|
||||
const files = Array.from(list ?? []);
|
||||
if (files.length === 0) return false;
|
||||
const paths = localCandidates(dt, files);
|
||||
if (!paths) return attachImages(files);
|
||||
setUpload("uploading");
|
||||
void sameFilesHere(paths, files).then((same) => {
|
||||
if (!same) return void attachImages(files);
|
||||
setUpload(null);
|
||||
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||
});
|
||||
return true;
|
||||
};
|
||||
// Anything attached since — a path, a panel row, a + menu pick — ends a past
|
||||
// upload's error; not the files that uploaded beside the one refused.
|
||||
const keepError = useRef(false);
|
||||
const imageCount = useRef(images.length);
|
||||
useEffect(() => {
|
||||
const grew = images.length > imageCount.current;
|
||||
imageCount.current = images.length;
|
||||
if (grew && !keepError.current) setUpload((u) => (u === "uploading" ? u : null));
|
||||
keepError.current = false;
|
||||
}, [images.length]);
|
||||
const removeImage = (img: PromptImage) => {
|
||||
if (img.url) URL.revokeObjectURL(img.url);
|
||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||
@@ -452,7 +509,7 @@ export function PromptEditor({
|
||||
} else if (skill) {
|
||||
e.preventDefault();
|
||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
|
||||
else {
|
||||
// Only text (a file manager's or an editor's `file://` list, a
|
||||
// selection, a link): what the terminal beneath would paste.
|
||||
@@ -617,7 +674,7 @@ export function PromptEditor({
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
||||
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
|
||||
else {
|
||||
// A very long paste goes in as a text file, like Claude Desktop:
|
||||
// the prompt then carries its path, never the text (#68: a CLI's
|
||||
@@ -661,10 +718,10 @@ export function PromptEditor({
|
||||
</div>
|
||||
{upload && (
|
||||
<p
|
||||
role={upload === "failed" ? "alert" : "status"}
|
||||
className={`mt-1 text-[11px] ${upload === "failed" ? "text-status-danger" : "text-fg-muted"}`}
|
||||
role={upload === "uploading" ? "status" : "alert"}
|
||||
className={`mt-1 text-[11px] ${upload === "uploading" ? "text-fg-muted" : "text-status-danger"}`}
|
||||
>
|
||||
{t(`drop.${upload}`)}
|
||||
{t(`drop.${upload}`, { mb: limitMb })}
|
||||
</p>
|
||||
)}
|
||||
{kind && (
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
||||
* reads them (an image as an image) when the prompt is sent. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* reads them (an image as an image) when the prompt is sent; a browser on the
|
||||
* dashboard's own machine pastes a file manager's files by their own paths
|
||||
* instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||
@@ -63,12 +65,17 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { writeClipboard } from "../../lib/clipboard";
|
||||
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { claudeMention } from "../../lib/promptAttach";
|
||||
import {
|
||||
claudeMention,
|
||||
isLocalBrowser,
|
||||
localCandidates,
|
||||
sameFilesHere,
|
||||
} from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed";
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
|
||||
|
||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
@@ -198,6 +205,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const [drop, setDrop] = useState<DropState | null>(null);
|
||||
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
|
||||
const [limitMb, setLimitMb] = useState(0);
|
||||
// The socket dropped after it had been up: keystrokes are not reaching tmux.
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
|
||||
@@ -212,10 +221,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
// only goes away when the pointer leaves the terminal itself.
|
||||
const dragDepth = useRef(0);
|
||||
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flash = (state: DropState) => {
|
||||
const flash = (state: DropState, ms = 2500) => {
|
||||
setDrop(state);
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), 2500);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), ms);
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -240,7 +249,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
void attachFiles(Array.from(e.dataTransfer.files));
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
void attachFiles(files, localCandidates(e.dataTransfer, files));
|
||||
};
|
||||
|
||||
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
||||
@@ -255,24 +265,42 @@ export const TerminalView = memo(function TerminalView({
|
||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (files.length > 0) void attachFiles(files);
|
||||
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
|
||||
else termRef.current?.input("\x16", true);
|
||||
};
|
||||
|
||||
const attachFiles = async (files: File[]) => {
|
||||
/** Pastes the files' paths: their own (`local`, the `file://` paths a
|
||||
* browser on the dashboard's machine is handed beside the bytes) once the
|
||||
* server finds the same files there — nothing uploaded —, else the paths
|
||||
* the uploads were saved at. A refused file does not cost the others. */
|
||||
const attachFiles = async (files: File[], local: string[] | null = null) => {
|
||||
if (files.length === 0) return;
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
|
||||
let paths = local && (await sameFilesHere(local, files)) ? local : null;
|
||||
const errors: unknown[] = [];
|
||||
if (!paths) {
|
||||
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
|
||||
paths = results.flatMap((r) => {
|
||||
if (r.status === "fulfilled") return [r.value.path];
|
||||
errors.push(r.reason);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
if (paths.length) {
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees pasted paths rather than typed keystrokes.
|
||||
const asText = tool === "claude" ? claudeMention : (p: string) => p;
|
||||
termRef.current?.paste(saved.map((r) => asText(r.path)).join(" ") + " ");
|
||||
termRef.current?.paste(paths.map(asText).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
} catch {
|
||||
flash("failed");
|
||||
}
|
||||
// Over the server's limit (ETOOBIG): refused before the body is sent.
|
||||
const tooBig = errors
|
||||
.map((e) => e as { code?: string; maxBytes?: number } | null)
|
||||
.find((e) => e?.code === "ETOOBIG");
|
||||
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||
if (!errors.length) setDrop(null);
|
||||
else if (!tooBig) flash("failed");
|
||||
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -878,10 +906,12 @@ export const TerminalView = memo(function TerminalView({
|
||||
<div
|
||||
data-testid="terminal-drop"
|
||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
|
||||
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
{t(`drop.${drop}`, { mb: limitMb })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* @file ChatAddMenu.test.tsx
|
||||
* @description The `+` menu's existing-resource pickers: Project files (a
|
||||
* search with folders, attached as `@path`), the workspace's Context files
|
||||
* (files only, none missing) and this browser's Recent uploads.
|
||||
* (files only, none missing), this browser's Recent uploads and, for a
|
||||
* browser on the dashboard's machine, a file chosen in the OS's own picker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ChatAddMenu } from "../ChatAddMenu";
|
||||
import { api } from "../../../lib/api";
|
||||
@@ -21,6 +22,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
|
||||
lanes: {
|
||||
pickPath: vi.fn().mockResolvedValue({ path: "/home/u/Videos/demo clip.mp4" }),
|
||||
sources: vi.fn().mockResolvedValue({
|
||||
sources: [
|
||||
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
|
||||
@@ -61,6 +63,28 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("ChatAddMenu — a file on this computer", () => {
|
||||
it("attaches the file the OS picker chose by its path, uploading nothing", async () => {
|
||||
const onAttach = renderMenu();
|
||||
fireEvent.click(screen.getByTestId("chat-add-pick-local"));
|
||||
expect(api.lanes.pickPath).toHaveBeenCalledWith("file", "/repo");
|
||||
await vi.waitFor(() =>
|
||||
expect(onAttach).toHaveBeenCalledWith([
|
||||
{ path: '@"/home/u/Videos/demo clip.mp4"', name: "demo clip.mp4" },
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("is not offered to a browser on another machine", () => {
|
||||
// Only a loopback address: a name that merely starts with "localhost" is not one.
|
||||
vi.stubGlobal("location", { ...window.location, hostname: "localhost.example.com" });
|
||||
renderMenu();
|
||||
expect(screen.queryByTestId("chat-add-pick-local")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatAddMenu — existing resources", () => {
|
||||
it("searches the project, folders included, and attaches a pick as @path", async () => {
|
||||
const onAttach = renderMenu();
|
||||
|
||||
@@ -56,7 +56,11 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
||||
}),
|
||||
},
|
||||
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
||||
run: {
|
||||
files: r({ items: [] }),
|
||||
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||
localPaths: r({ sizes: [1] }),
|
||||
},
|
||||
ccConfig: {
|
||||
commands: r({
|
||||
items: [
|
||||
@@ -342,6 +346,120 @@ describe("PromptEditor — newlines and images", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a local file manager's files by their own paths, uploading nothing", async () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value="read"
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
|
||||
const data: Record<string, string> = { "text/uri-list": "file:///home/u/demo.mp4" };
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [big], types: ["Files"], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
expect(await screen.findByText("demo.mp4")).toBeTruthy();
|
||||
expect(api.run.localPaths).toHaveBeenCalledWith(["/home/u/demo.mp4"]);
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
|
||||
});
|
||||
|
||||
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
|
||||
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
|
||||
const onImagesChange = vi.fn();
|
||||
render(
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={[]}
|
||||
onImagesChange={onImagesChange}
|
||||
/>
|
||||
);
|
||||
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
|
||||
const data: Record<string, string> = { "text/uri-list": "file:///Users/me/spec.pdf" };
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [doc], types: ["Files"], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
await vi.waitFor(() => expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined));
|
||||
});
|
||||
|
||||
it("keeps the files that uploaded when another one is refused", async () => {
|
||||
vi.mocked(api.run.dropFile)
|
||||
.mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/a-ok.txt" })
|
||||
.mockRejectedValueOnce(Object.assign(new Error("too big"), { code: "ETOOBIG" }));
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
return (
|
||||
<>
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
/>
|
||||
<output data-testid="sent">{promptWithImages("", images)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Harness />);
|
||||
fireEvent.drop(box(), {
|
||||
dataTransfer: { files: [new File(["x"], "ok.txt"), new File(["y"], "huge.iso")] },
|
||||
});
|
||||
expect(await screen.findByText("ok.txt")).toBeTruthy();
|
||||
expect(screen.getByTestId("sent").textContent).toBe("/tmp/agenthub-drops-1/a-ok.txt");
|
||||
expect(screen.getByRole("alert").textContent).toMatch(/too large/);
|
||||
// Anything attached afterwards — here a Files panel row — ends the error.
|
||||
const data: Record<string, string> = {
|
||||
[ATTACH_MIME]: JSON.stringify({ path: "@src/a.ts", name: "a.ts" }),
|
||||
};
|
||||
fireEvent.drop(box(), { dataTransfer: { getData: (k: string) => data[k] ?? "" } });
|
||||
expect(await screen.findByText("a.ts")).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("says a file over the server's limit is too big, and how to attach it", async () => {
|
||||
vi.mocked(api.run.dropFile).mockRejectedValueOnce(
|
||||
Object.assign(new Error("too big"), { code: "ETOOBIG", status: 413, maxBytes: 20971520 })
|
||||
);
|
||||
render(
|
||||
<PromptEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
placeholder="Ask anything…"
|
||||
slash={false}
|
||||
fileCwd="/home/u"
|
||||
images={[]}
|
||||
onImagesChange={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.drop(box(), { dataTransfer: { files: [new File(["x"], "huge.iso")] } });
|
||||
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||
/too large to upload \(max 20 MB\).*Choose a file on this computer/
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
|
||||
function Harness() {
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
@@ -593,7 +711,10 @@ describe("promptForPane — images apart, every other file in the text", () => {
|
||||
// A Windows path inside a paste of text stayed a plain path in claude; a
|
||||
// paste that is only the path becomes [Image #n].
|
||||
const images: PromptImage[] = [
|
||||
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
|
||||
{
|
||||
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||
name: "image.png",
|
||||
},
|
||||
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
|
||||
@@ -611,14 +732,17 @@ describe("promptForPane — images apart, every other file in the text", () => {
|
||||
// A bare pasted path stayed plain text on Windows, and a path in the
|
||||
// command-line prompt is never an image; claude reads an @ mention at submit.
|
||||
const images: PromptImage[] = [
|
||||
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
|
||||
{
|
||||
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||
name: "image.png",
|
||||
},
|
||||
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
|
||||
];
|
||||
expect(promptForPane(" look ", images, true)).toEqual({
|
||||
text:
|
||||
'look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png ' +
|
||||
"look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png " +
|
||||
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
|
||||
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
|
||||
files: [],
|
||||
|
||||
@@ -26,8 +26,14 @@ const inputMock = vi.fn();
|
||||
// The newest terminal's hidden input, where the browser's IME types.
|
||||
let textarea: HTMLTextAreaElement;
|
||||
const dropFileMock = vi.fn();
|
||||
const localPathsMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
||||
api: {
|
||||
run: {
|
||||
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||
localPaths: (paths: string[]) => localPathsMock(paths),
|
||||
},
|
||||
},
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
@@ -512,6 +518,23 @@ describe("TerminalView", () => {
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("pastes a local file manager's own paths, uploading nothing", async () => {
|
||||
dropFileMock.mockReset();
|
||||
localPathsMock.mockReset().mockResolvedValue({ sizes: [1] });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
|
||||
const data: Record<string, string> = {
|
||||
"text/uri-list": "file:///home/u/My%20Videos/demo.mp4",
|
||||
};
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [big], getData: (k: string) => data[k] ?? "" },
|
||||
});
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith('@"/home/u/My Videos/demo.mp4" '));
|
||||
expect(localPathsMock).toHaveBeenCalledWith(["/home/u/My Videos/demo.mp4"]);
|
||||
expect(dropFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pastes every path of several dropped files, images or not", async () => {
|
||||
dropFileMock
|
||||
.mockReset()
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
|
||||
"deleteConfirm": "Delete",
|
||||
"installTitle": "Install “{{title}}”?",
|
||||
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/. A skill can carry scripts your agent will run — only install what you trust:",
|
||||
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/, so every AI tool and session finds it. A skill can carry scripts your agent will run — only install what you trust:",
|
||||
"installPluginBody": "Runs, as your account: claude plugin marketplace add {{market}} and claude plugin install {{name}} --scope user. Only install what you trust.",
|
||||
"installConfirm": "Install",
|
||||
"replaceTitle": "Replace your skill “{{name}}”?",
|
||||
@@ -78,7 +78,7 @@
|
||||
"skill.tab.write": "Write new",
|
||||
"skill.tab.existing": "From my skills",
|
||||
"skill.name": "Skill name",
|
||||
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ and runs as /{{slug}}",
|
||||
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ (and ~/.agents/skills/) and runs as /{{slug}}",
|
||||
"skill.slugBad": "Use letters, digits, '.', '_' or '-'",
|
||||
"skill.description": "Description — when Claude should use this skill",
|
||||
"skill.descriptionHint": "e.g. Use when writing release notes from the git log",
|
||||
@@ -110,5 +110,38 @@
|
||||
"file.fromComputer": "From your computer (≤ 25 MB)",
|
||||
"file.upload": "Upload",
|
||||
"file.tooBig": "The file is larger than 25 MB.",
|
||||
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote"
|
||||
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote",
|
||||
"editTitle": "Edit “{{title}}”",
|
||||
"edit.title": "Title",
|
||||
"edit.description": "Description",
|
||||
"previewFailed": "Could not load this file.",
|
||||
"truncated": "Showing the first 1 MB — download the file to see all of it.",
|
||||
"tab.all": "All",
|
||||
"installedBadge": "Installed",
|
||||
"installsTitle": "Installs",
|
||||
"installsYou": "You",
|
||||
"installsUnknown": "A removed user",
|
||||
"installsReplaced": "replaced an older copy",
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
||||
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
||||
"reinstall": "Reinstall",
|
||||
"reinstallHint": "Write this version over your installed copy",
|
||||
"reinstallTitle": "Reinstall “{{title}}”?",
|
||||
"reinstallBody": "Your installed ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are replaced with this version. The current copy is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||
"deleteBodyShared": "It is shared, so it also disappears from the Hub for everyone. To keep it but stop sharing, use Unshare instead. Anything already installed from it stays installed.",
|
||||
"deleteBodyKeep": "It leaves your library but stays in the Hub, because it is shared. To take it off the Hub too, use Remove from Hub afterwards (or Unshare first).",
|
||||
"deleteKept": "Removed from your library — still in the Hub",
|
||||
"hubOnly": "Hub only",
|
||||
"hubOnlyHint": "You removed it from your library; it stays shared in the Hub",
|
||||
"hubRemove": "Remove from Hub",
|
||||
"hubRemoveTitle": "Remove “{{title}}” from the Hub?",
|
||||
"hubRemoveBody": "It is no longer in your library, so it is deleted for good. Anything already installed from it stays installed.",
|
||||
"edit.otherFiles": "Its other {{count}} file(s) are kept as they are.",
|
||||
"edit.reinstallNote": "Your installed copy keeps the old version until you Reinstall it.",
|
||||
"preview.instructions": "Instructions",
|
||||
"preview.frontmatter": "Frontmatter (SKILL.md header)",
|
||||
"preview.whenUsed": "When Claude uses it",
|
||||
"preview.noDescription": "No description",
|
||||
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
"paste": "Drop to paste into the terminal",
|
||||
"uploading": "Uploading…",
|
||||
"failed": "Could not attach the file",
|
||||
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
|
||||
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
|
||||
"remove": "Remove attachment",
|
||||
"view": "View image"
|
||||
},
|
||||
@@ -97,6 +99,9 @@
|
||||
"notStopped": "The terminal is not connected — nothing was stopped.",
|
||||
"add": "Add files, skills…",
|
||||
"upload": "Upload or add files",
|
||||
"pickLocal": "Choose a file on this computer",
|
||||
"pickLocalHint": "Attach a file on this machine by its path — nothing is uploaded",
|
||||
"pickLocalOpen": "The file picker is open on this computer's screen…",
|
||||
"projectFiles": "Project files",
|
||||
"contextFiles": "Context files",
|
||||
"recentUploads": "Recent uploads",
|
||||
|
||||
@@ -216,7 +216,21 @@
|
||||
"agentsStill_other": "{{count}} agents still running",
|
||||
"agentsRunning_one": "{{count}} agent running in the background",
|
||||
"agentsRunning_other": "{{count}} agents running in the background",
|
||||
"agentContext": "{{tokens}} tokens"
|
||||
"agentContext": "{{tokens}} tokens",
|
||||
"agent": {
|
||||
"open": "View conversation",
|
||||
"prompt": "Task",
|
||||
"background": "background",
|
||||
"toolUses_one": "{{count}} tool call",
|
||||
"toolUses_other": "{{count}} tool calls",
|
||||
"status": {
|
||||
"running": "running",
|
||||
"done": "done",
|
||||
"error": "failed",
|
||||
"stopped": "stopped"
|
||||
},
|
||||
"hide": "Hide conversation"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"deleteBody": "Mục này bị gỡ khỏi thư viện của bạn và Hub. Những bản người khác đã cài vẫn giữ nguyên.",
|
||||
"deleteConfirm": "Xoá",
|
||||
"installTitle": "Cài “{{title}}”?",
|
||||
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ của chính bạn. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
|
||||
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ của chính bạn, để mọi AI tool và mọi phiên đều dùng được. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
|
||||
"installPluginBody": "Chạy dưới tài khoản của bạn: claude plugin marketplace add {{market}} và claude plugin install {{name}} --scope user. Chỉ cài thứ bạn tin.",
|
||||
"installConfirm": "Cài",
|
||||
"replaceTitle": "Thay skill “{{name}}” của bạn?",
|
||||
@@ -78,7 +78,7 @@
|
||||
"skill.tab.write": "Viết mới",
|
||||
"skill.tab.existing": "Từ skill có sẵn",
|
||||
"skill.name": "Tên skill",
|
||||
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ và gọi bằng /{{slug}}",
|
||||
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ (và ~/.agents/skills/) và gọi bằng /{{slug}}",
|
||||
"skill.slugBad": "Chỉ dùng chữ, số, '.', '_' hoặc '-'",
|
||||
"skill.description": "Mô tả — khi nào Claude nên dùng skill này",
|
||||
"skill.descriptionHint": "VD: Dùng khi viết release notes từ git log",
|
||||
@@ -110,5 +110,38 @@
|
||||
"file.fromComputer": "Từ máy tính (≤ 25 MB)",
|
||||
"file.upload": "Tải lên",
|
||||
"file.tooBig": "File lớn hơn 25 MB.",
|
||||
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi"
|
||||
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi",
|
||||
"editTitle": "Sửa “{{title}}”",
|
||||
"edit.title": "Tiêu đề",
|
||||
"edit.description": "Mô tả",
|
||||
"previewFailed": "Không tải được file này.",
|
||||
"truncated": "Chỉ hiển thị 1 MB đầu — tải file về để xem toàn bộ.",
|
||||
"tab.all": "Tất cả",
|
||||
"installedBadge": "Đã cài",
|
||||
"installsTitle": "Lượt cài",
|
||||
"installsYou": "Bạn",
|
||||
"installsUnknown": "Người dùng đã xoá",
|
||||
"installsReplaced": "thay bản cũ",
|
||||
"installsPluginWhere": "claude plugin install --scope user",
|
||||
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
|
||||
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
|
||||
"reinstall": "Cài lại",
|
||||
"reinstallHint": "Ghi bản này đè lên bản bạn đã cài",
|
||||
"reinstallTitle": "Cài lại “{{title}}”?",
|
||||
"reinstallBody": "Bản đã cài ở ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được thay bằng bản này. Bản hiện tại được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
|
||||
"deleteBodyShared": "Mục này đang được chia sẻ nên cũng sẽ biến mất khỏi Hub với mọi người. Muốn giữ lại nhưng thôi chia sẻ thì dùng Bỏ chia sẻ. Những gì đã cài từ nó vẫn được giữ.",
|
||||
"deleteBodyKeep": "Mục này rời khỏi thư viện của bạn nhưng vẫn ở trên Hub vì đang được chia sẻ. Muốn gỡ khỏi Hub luôn thì sau đó bấm Gỡ khỏi Hub (hoặc Bỏ chia sẻ trước).",
|
||||
"deleteKept": "Đã xoá khỏi thư viện — vẫn còn trên Hub",
|
||||
"hubOnly": "Chỉ trên Hub",
|
||||
"hubOnlyHint": "Bạn đã xoá khỏi thư viện; mục này vẫn được chia sẻ trên Hub",
|
||||
"hubRemove": "Gỡ khỏi Hub",
|
||||
"hubRemoveTitle": "Gỡ “{{title}}” khỏi Hub?",
|
||||
"hubRemoveBody": "Mục này không còn trong thư viện của bạn nên sẽ bị xoá hẳn. Những gì đã cài từ nó vẫn được giữ.",
|
||||
"edit.otherFiles": "{{count}} file khác của skill được giữ nguyên.",
|
||||
"edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bấm Cài lại.",
|
||||
"preview.instructions": "Hướng dẫn",
|
||||
"preview.frontmatter": "Frontmatter (phần đầu SKILL.md)",
|
||||
"preview.whenUsed": "Khi nào Claude dùng",
|
||||
"preview.noDescription": "Chưa có mô tả",
|
||||
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/"
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@
|
||||
"paste": "Thả để dán vào terminal",
|
||||
"uploading": "Đang tải lên…",
|
||||
"failed": "Không đính kèm được file",
|
||||
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
|
||||
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
|
||||
"remove": "Bỏ đính kèm",
|
||||
"view": "Xem ảnh"
|
||||
},
|
||||
@@ -97,6 +99,9 @@
|
||||
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
|
||||
"add": "Thêm file, skill…",
|
||||
"upload": "Tải lên hoặc thêm file",
|
||||
"pickLocal": "Chọn file trên máy này",
|
||||
"pickLocalHint": "Đính kèm file trên máy này bằng đường dẫn — không tải lên gì cả",
|
||||
"pickLocalOpen": "Hộp chọn file đang mở trên màn hình máy này…",
|
||||
"projectFiles": "File trong dự án",
|
||||
"contextFiles": "File ngữ cảnh",
|
||||
"recentUploads": "Đã tải lên gần đây",
|
||||
|
||||
@@ -216,7 +216,21 @@
|
||||
"agentsStill_other": "còn {{count}} agent đang chạy",
|
||||
"agentsRunning_one": "{{count}} agent đang chạy nền",
|
||||
"agentsRunning_other": "{{count}} agent đang chạy nền",
|
||||
"agentContext": "{{tokens}} token"
|
||||
"agentContext": "{{tokens}} token",
|
||||
"agent": {
|
||||
"open": "Xem hội thoại",
|
||||
"prompt": "Nhiệm vụ",
|
||||
"background": "chạy nền",
|
||||
"toolUses_one": "{{count}} lần gọi tool",
|
||||
"toolUses_other": "{{count}} lần gọi tool",
|
||||
"status": {
|
||||
"running": "đang chạy",
|
||||
"done": "xong",
|
||||
"error": "lỗi",
|
||||
"stopped": "đã dừng"
|
||||
},
|
||||
"hide": "Ẩn hội thoại"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"agent": "Agent",
|
||||
|
||||
+105
-35
@@ -796,6 +796,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
runId: body?.error?.run_id as string | undefined,
|
||||
// EEXISTS from the File Explorer: the names already taken.
|
||||
names: body?.error?.names as string[] | undefined,
|
||||
// ETOOBIG from a prompt drop: the server's own limit.
|
||||
maxBytes: body?.error?.max_bytes as number | undefined,
|
||||
});
|
||||
}
|
||||
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
|
||||
@@ -861,6 +863,44 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
||||
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
|
||||
}
|
||||
|
||||
/**
|
||||
* POST `payload` (a file or a batch) as a raw stream to `url`, reporting upload
|
||||
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
|
||||
*/
|
||||
const sendRaw = <T>(url: string, payload: Blob, onProgress?: (sent: number) => void) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${BASE}${url}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
const token = dashboardToken();
|
||||
if (token) xhr.setRequestHeader("x-dashboard-token", token);
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||
xhr.onload = () => {
|
||||
let body: { error?: { code?: string; message?: string; names?: string[] } } = {};
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(body as T);
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401 && body.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
reject(
|
||||
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
|
||||
code: body.error?.code,
|
||||
status: xhr.status,
|
||||
names: body.error?.names,
|
||||
})
|
||||
);
|
||||
};
|
||||
xhr.send(payload);
|
||||
});
|
||||
|
||||
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
|
||||
export interface FsHome {
|
||||
id: string;
|
||||
@@ -2764,40 +2804,45 @@ export const api = {
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
) =>
|
||||
new Promise<{ path: string; skipped?: boolean }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${BASE}${fsUrl(t, "/upload", new URLSearchParams({ path, conflict }))}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
const token = dashboardToken();
|
||||
if (token) xhr.setRequestHeader("x-dashboard-token", token);
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||
xhr.onload = () => {
|
||||
let body: { error?: { code?: string; message?: string; names?: string[] } } & {
|
||||
path?: string;
|
||||
skipped?: boolean;
|
||||
} = {};
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(body as { path: string; skipped?: boolean });
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401 && body.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
reject(
|
||||
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
|
||||
code: body.error?.code,
|
||||
status: xhr.status,
|
||||
names: body.error?.names,
|
||||
})
|
||||
);
|
||||
};
|
||||
xhr.send(file);
|
||||
sendRaw<{ path: string; skipped?: boolean }>(
|
||||
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
|
||||
file,
|
||||
onProgress
|
||||
),
|
||||
/**
|
||||
* Many small files in ONE request, under the folder `dir` (each `rel`
|
||||
* relative to it): a 4-byte length, the JSON file list, then the bytes.
|
||||
* The Blob is only composed, never read into memory here.
|
||||
*/
|
||||
uploadBatch: (
|
||||
t: FsPlace,
|
||||
dir: string,
|
||||
files: { file: Blob; rel: string }[],
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
) => {
|
||||
const head = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
files: files.map((f) => ({
|
||||
path: f.rel,
|
||||
size: f.file.size,
|
||||
mtime: f.file instanceof File ? f.file.lastModified : undefined,
|
||||
})),
|
||||
})
|
||||
);
|
||||
const len = new Uint8Array(4);
|
||||
new DataView(len.buffer).setUint32(0, head.length);
|
||||
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
||||
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
|
||||
onProgress
|
||||
);
|
||||
},
|
||||
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
|
||||
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
|
||||
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ dir, paths }),
|
||||
}),
|
||||
},
|
||||
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
|
||||
@@ -2945,18 +2990,24 @@ export const api = {
|
||||
source?: string;
|
||||
/** plugin only: the marketplace source. */
|
||||
market?: string;
|
||||
/** skill only: new contents, written as a new snapshot. */
|
||||
skill?: { name: string; description: string; content: string };
|
||||
}
|
||||
) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||
/** `kept`: a shared one left your library but stays in the Hub. */
|
||||
remove: (id: number) =>
|
||||
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||
install: (id: number, replace = false) =>
|
||||
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
}),
|
||||
/** GET /api/artifacts/:id/installs — who installed it, when, and where. */
|
||||
installs: (id: number) => request<ArtifactInstalls>(`/artifacts/${id}/installs`),
|
||||
/** One of the snapshot's own files, served sandboxed. */
|
||||
rawUrl: (id: number, path?: string, download = false) => {
|
||||
const qs = new URLSearchParams();
|
||||
@@ -3134,6 +3185,14 @@ export const api = {
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
});
|
||||
},
|
||||
/** POST /api/run/local-paths — each absolute path's size when it is a
|
||||
* regular file on the dashboard's machine, else null; 403 EREMOTE unless
|
||||
* the browser is on that machine (and never in server mode). */
|
||||
localPaths: (paths: string[]) =>
|
||||
request<{ sizes: (number | null)[] }>("/run/local-paths", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
@@ -4203,10 +4262,21 @@ export interface Artifact {
|
||||
shared: boolean;
|
||||
sharedAt: string | null;
|
||||
installs: number;
|
||||
/** False once its owner removed it from their library while shared: Hub only. */
|
||||
inLibrary: boolean;
|
||||
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
|
||||
installed?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
|
||||
export interface ArtifactInstalls {
|
||||
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[];
|
||||
/** Installs counted before the list was kept. */
|
||||
unrecorded: number;
|
||||
}
|
||||
|
||||
/** POST /api/artifacts/plugin/inspect — a marketplace and the plugins it lists. */
|
||||
export interface MarketplaceInfo {
|
||||
/** The source as stored (owner/repo, a git URL or a marketplace.json link). */
|
||||
|
||||
@@ -5,12 +5,16 @@
|
||||
* `PromptEditor`'s drop) or from a Chat pane's activity panel
|
||||
* (`dragAttachments`, `dragSkill`: a skill becomes the prompt's `/command`) and the files uploaded through a prompt box in the
|
||||
* last day (`recentUploads`, kept in this browser), and `claudeMention`, the
|
||||
* `@` form a file is handed to `claude` in. An attachment is a path sent with
|
||||
* the prompt, so nothing here touches the server.
|
||||
* `@` form a file is handed to `claude` in, and what a browser on the
|
||||
* dashboard's own machine attaches by path instead of uploading
|
||||
* (`localCandidates`, checked by the server in `sameFilesHere` —
|
||||
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path
|
||||
* sent with the prompt; that check is the one call here to the server.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { DragEvent } from "react";
|
||||
import { api } from "./api";
|
||||
|
||||
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
|
||||
export interface Attachment {
|
||||
@@ -65,6 +69,76 @@ export function droppedAttachments(dt: DataTransfer | null | undefined): Attachm
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this browser reaches the dashboard by a loopback address — a
|
||||
* hint only: an SSH tunnel's browser does too, from another machine, so a
|
||||
* dropped path is used only once `sameFilesHere` has checked it. */
|
||||
export function isLocalBrowser(): boolean {
|
||||
return /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(window.location.hostname);
|
||||
}
|
||||
|
||||
/** The `file://` paths a drop or paste carries beside its `files`, when this
|
||||
* may be a browser on the dashboard's machine — read now: a DataTransfer is
|
||||
* empty once its event is over. Null when there is nothing to check. */
|
||||
export function localCandidates(
|
||||
dt: DataTransfer | null | undefined,
|
||||
files: File[]
|
||||
): string[] | null {
|
||||
if (!isLocalBrowser()) return null;
|
||||
const paths = droppedFilePaths(dt);
|
||||
return paths && paths.length === files.length ? paths : null;
|
||||
}
|
||||
|
||||
/** Whether `paths` are the dropped `files` as the dashboard's machine sees
|
||||
* them: every one a regular file there, and the sizes the same. Anything
|
||||
* else (an SSH tunnel's laptop paths, server mode, an error) is false, and
|
||||
* the caller uploads the bytes as before. */
|
||||
export async function sameFilesHere(paths: string[], files: File[]): Promise<boolean> {
|
||||
try {
|
||||
const { sizes } = await api.run.localPaths(paths);
|
||||
const want = files.map((f) => f.size).sort((a, b) => a - b);
|
||||
const have = sizes.slice().sort((a, b) => (a ?? -1) - (b ?? -1));
|
||||
return have.length === want.length && have.every((s, i) => s === want[i]);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The local files a drop or a paste names as `file://` URIs (a file
|
||||
* manager's, an editor's), as absolute paths — or null when it names none,
|
||||
* or anything that is not one. `file:///C:/x` is `C:/x`. */
|
||||
export function droppedFilePaths(dt: DataTransfer | null | undefined): string[] | null {
|
||||
try {
|
||||
const paths = (dt?.getData("text/uri-list") ?? "")
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l && !l.startsWith("#"))
|
||||
.map((l) => {
|
||||
try {
|
||||
const u = new URL(l.trim());
|
||||
if (u.protocol !== "file:" || (u.host && u.host !== "localhost")) return null;
|
||||
return decodeURIComponent(u.pathname).replace(/^\/([A-Za-z]:\/)/, "$1");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
return paths.length && paths.every(Boolean) ? (paths as string[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A file on this machine, by its absolute path, as an attachment. */
|
||||
export function localAttachment(path: string): Attachment {
|
||||
return {
|
||||
// A space would end the mention: Claude Code reads `@"…"` whole.
|
||||
path: /\s/.test(path) ? `@"${path}"` : `@${path}`,
|
||||
name:
|
||||
path
|
||||
.replace(/[\\/]$/, "")
|
||||
.split(/[\\/]/)
|
||||
.pop() || path,
|
||||
};
|
||||
}
|
||||
|
||||
/** A drop that carries no file and none of ours, only text — a file manager
|
||||
* or an editor (VS Code) that hands over `file://` URIs, a selection, a
|
||||
* link: the local `file://` URIs as `@path` attachments, else the text to
|
||||
@@ -73,28 +147,8 @@ export function droppedText(
|
||||
dt: DataTransfer | null | undefined
|
||||
): { attachments: Attachment[] } | { text: string } | null {
|
||||
try {
|
||||
const paths = (dt?.getData("text/uri-list") ?? "")
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l && !l.startsWith("#"))
|
||||
.map((l) => {
|
||||
try {
|
||||
const u = new URL(l.trim());
|
||||
return u.protocol === "file:" && (!u.host || u.host === "localhost")
|
||||
? decodeURIComponent(u.pathname)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
if (paths.length && paths.every(Boolean)) {
|
||||
return {
|
||||
attachments: (paths as string[]).map((p) => ({
|
||||
// A space would end the mention: Claude Code reads `@"…"` whole.
|
||||
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
|
||||
name: p.replace(/\/$/, "").split("/").pop() || p,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const paths = droppedFilePaths(dt);
|
||||
if (paths) return { attachments: paths.map(localAttachment) };
|
||||
const text = dt?.getData("text/plain") ?? "";
|
||||
return text.trim() ? { text } : null;
|
||||
} catch {
|
||||
|
||||
@@ -3228,6 +3228,17 @@ export interface TranscriptContent {
|
||||
/** Present on an AskUserQuestion "tool_result": question text → the option
|
||||
* the user picked, or what they typed. */
|
||||
answers?: Record<string, string>;
|
||||
/** Present on an Agent/Task "tool_result": the subagent it ran — its id
|
||||
* (the transcript's `agent_id`), model and, for a foreground run, its time,
|
||||
* tokens and tool calls (null when the result does not say). */
|
||||
agent?: {
|
||||
id: string;
|
||||
model: string | null;
|
||||
status: string | null;
|
||||
duration_ms: number | null;
|
||||
tokens: number | null;
|
||||
tool_uses: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
|
||||
@@ -3313,6 +3324,9 @@ export interface TranscriptInfo {
|
||||
/** The subagent type for subagent entries; null/absent otherwise (e.g. the
|
||||
* main or compaction entries). */
|
||||
subagent_type?: string | null;
|
||||
/** The Agent call that launched this subagent (its `tool_use` id), when its
|
||||
* meta file names it. */
|
||||
tool_use_id?: string | null;
|
||||
/** Whether a JSONL transcript file was actually found on disk for this entry -
|
||||
* false means the entry exists in the DB but its transcript isn't available
|
||||
* (e.g. the raw file was pruned), so the UI disables that picker option. */
|
||||
|
||||
+588
-138
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,7 @@ export function GitCredentialsConfig() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* The page header above already names the section. */}
|
||||
<p className="max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
|
||||
<button
|
||||
@@ -128,7 +128,7 @@ export function GitCredentialsConfig() {
|
||||
|
||||
{form && (
|
||||
<form
|
||||
className="grid max-w-2xl gap-2 rounded-lg border border-border p-3 sm:grid-cols-3"
|
||||
className="grid gap-2 rounded-lg border border-border bg-surface-1 p-3 sm:grid-cols-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
@@ -159,18 +159,18 @@ export function GitCredentialsConfig() {
|
||||
onChange={(e) => setForm({ ...form, token: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<div className="flex gap-2 sm:col-span-3">
|
||||
<div className="flex justify-end gap-2 sm:col-span-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !form.url.trim() || !form.username.trim() || !form.token}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(null)}
|
||||
className="rounded border border-border-light px-3 py-1 text-xs text-fg-secondary hover:bg-surface-3"
|
||||
className="rounded-lg border border-border-light px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* @file Tests for the `/artifacts` page: the library lists what the server
|
||||
* answers, the Hub tab and Share appear only in server mode, a skill is
|
||||
* answers (one merged list; All/My library/Hub filter and Share only in
|
||||
* server mode), an installed skill shows Installed, its install count lists
|
||||
* who installed it and where, a skill is
|
||||
* installed only after a confirmation (and a taken name asks to replace),
|
||||
* and a file preview is a sandboxed iframe with no `allow-*` token. Adding:
|
||||
* an HTML preview is a sandboxed iframe with no `allow-*` token, and a
|
||||
* skill's SKILL.md is rendered by the page itself. Adding:
|
||||
* a plugin is picked from the list its marketplace link answers (a link the
|
||||
* server cannot read opens the by-hand form), a skill is uploaded from one
|
||||
* Markdown file under the name the user types, or written in the dialog.
|
||||
@@ -29,6 +32,7 @@ vi.mock("../../lib/api", () => ({
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
install: vi.fn(),
|
||||
installs: vi.fn(),
|
||||
setInstallsEnabled: vi.fn(),
|
||||
skills: vi.fn(),
|
||||
addSkill: vi.fn(),
|
||||
@@ -58,6 +62,7 @@ const item = (over: Partial<Artifact>): Artifact => ({
|
||||
market: null,
|
||||
url: null,
|
||||
files: [{ path: "report.html", size: 10 }],
|
||||
inLibrary: true,
|
||||
size: 10,
|
||||
shared: false,
|
||||
sharedAt: null,
|
||||
@@ -208,9 +213,85 @@ describe("Artifacts", () => {
|
||||
installsEnabled: true,
|
||||
canManage: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
|
||||
// One merged list by default; Hub is a filter over it.
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
|
||||
await user.click(screen.getByRole("button", { name: "Hub" }));
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "hub" }))
|
||||
);
|
||||
});
|
||||
|
||||
it("lists who installed an artifact, when and where, from its install count", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installs: 3 })],
|
||||
});
|
||||
vi.mocked(api.artifacts.installs).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
user: "vi",
|
||||
mine: true,
|
||||
at: "2026-10-19T10:00:00Z",
|
||||
replaced: false,
|
||||
paths: ["/h/.claude/skills/deploy"],
|
||||
},
|
||||
{ user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true },
|
||||
],
|
||||
unrecorded: 1,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await user.click(await screen.findByRole("button", { name: "3 installs" }));
|
||||
const box = await screen.findByTestId("artifact-installs");
|
||||
expect(api.artifacts.installs).toHaveBeenCalledWith(2);
|
||||
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
|
||||
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
|
||||
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByTestId("artifact-installs")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a shared row in the Hub when its owner deletes it, and removes it from there on ask", async () => {
|
||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||
serverMode: true,
|
||||
canShare: true,
|
||||
installsEnabled: true,
|
||||
canManage: false,
|
||||
});
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 3, title: "hub-only", shared: true, inLibrary: false })],
|
||||
});
|
||||
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({ id: 3 }) } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-3");
|
||||
expect(within(card).getByText("Hub only")).toBeInTheDocument();
|
||||
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(within(card).getByRole("button", { name: "Remove from Hub" }));
|
||||
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
|
||||
const calls = vi.mocked(confirmDialog).mock.calls;
|
||||
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
||||
});
|
||||
|
||||
it("shows Installed instead of Install for a skill already installed", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })],
|
||||
});
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-2");
|
||||
expect(within(card).getByTestId("artifact-installed")).toHaveTextContent("Installed");
|
||||
expect(within(card).queryByRole("button", { name: "Install" })).toBeNull();
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
vi.mocked(api.artifacts.install).mockResolvedValueOnce({
|
||||
installed: "/h/.claude/skills/deploy",
|
||||
});
|
||||
await userEvent.setup().click(within(card).getByRole("button", { name: "Reinstall" }));
|
||||
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
|
||||
});
|
||||
|
||||
it("installs a skill only after a confirmation, and asks before replacing", async () => {
|
||||
@@ -241,6 +322,72 @@ describe("Artifacts", () => {
|
||||
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
|
||||
});
|
||||
|
||||
it("renders a skill's SKILL.md itself instead of an iframe", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({
|
||||
kind: "skill",
|
||||
title: "standup",
|
||||
files: [
|
||||
{ path: "SKILL.md", size: 10 },
|
||||
{ path: "notes.txt", size: 5 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const fetchMock = vi.fn(async (url: string) =>
|
||||
url.includes("SKILL.md")
|
||||
? new Response("---\nname: standup\n---\n# Daily standup\n\nSummarise.")
|
||||
: new Response("plain notes")
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("standup");
|
||||
await user.click(screen.getByRole("button", { name: "Preview" }));
|
||||
expect(await screen.findByRole("heading", { name: "Daily standup" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html");
|
||||
expect(screen.queryByTestId("artifact-preview")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "notes.txt" }));
|
||||
expect(await screen.findByText("plain notes")).toBeInTheDocument();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("edits a skill's name, description and instructions from its SKILL.md", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({
|
||||
kind: "skill",
|
||||
title: "standup",
|
||||
source: "standup",
|
||||
files: [{ path: "SKILL.md", size: 10 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({}) } as never);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("---\nname: standup\ndescription: Old\n---\n\nOld body"))
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("standup");
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
const name = await screen.findByLabelText(/Skill name/);
|
||||
expect(name).toHaveValue("standup");
|
||||
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
|
||||
await user.clear(screen.getByLabelText(/Description/));
|
||||
await user.type(screen.getByLabelText(/Description/), "New");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.update).toHaveBeenCalledWith(1, {
|
||||
title: "standup",
|
||||
skill: { name: "standup", description: "New", content: "Old body" },
|
||||
})
|
||||
);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("opens a card's actions on right-click", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
|
||||
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.12 (rebuild client to match)
|
||||
UI build v2.8.13 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user