mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 11:09:34 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
||||
import type { SessionEntry, SessionTreeNode } from "@/lib/types";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
interface Props {
|
||||
tree: SessionTreeNode[];
|
||||
activeLeafId: string | null;
|
||||
onLeafChange: (leafId: string | null) => void;
|
||||
/** When true, renders as a compact inline button for embedding in a top bar */
|
||||
inline?: boolean;
|
||||
/** When inline, use this ref's bounding rect to size/position the dropdown */
|
||||
containerRef?: React.RefObject<HTMLElement | null>;
|
||||
/** Controlled open state for inline mode */
|
||||
open?: boolean;
|
||||
/** Called when the button is clicked in inline mode */
|
||||
onToggle?: () => void;
|
||||
/** Whether a session is currently active (used to show appropriate empty reason) */
|
||||
hasSession?: boolean;
|
||||
/** When inline, render icon-only (no text label) to save horizontal space */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// Find the visible entry IDs on the path from root to activeLeafId.
|
||||
function buildActivePath(nodes: SessionTreeNode[], targetId: string | null): Set<string> {
|
||||
if (!targetId) return new Set();
|
||||
const target = targetId;
|
||||
function search(nodes: SessionTreeNode[], path: string[]): string[] | null {
|
||||
for (const node of nodes) {
|
||||
const next = [...path, node.entry.id];
|
||||
if (node.entry.id === target || node.compressedEntryIds?.includes(target)) {
|
||||
return next;
|
||||
}
|
||||
const found = search(node.children, next);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return new Set(search(nodes, []) ?? []);
|
||||
}
|
||||
|
||||
// Compress a visible linear chain into the first branching/leaf node.
|
||||
// Server-side compressed IDs also count as skipped nodes.
|
||||
function compress(node: SessionTreeNode): { node: SessionTreeNode; skipped: number } {
|
||||
let current = node;
|
||||
let skipped = current.compressedEntryIds?.length ?? 0;
|
||||
while (current.children.length === 1) {
|
||||
current = current.children[0];
|
||||
skipped += 1 + (current.compressedEntryIds?.length ?? 0);
|
||||
}
|
||||
return { node: current, skipped };
|
||||
}
|
||||
|
||||
function getLabel(entry: SessionEntry): string {
|
||||
if (entry.type === "message" && "message" in entry) {
|
||||
const msg = entry.message as { role: string; content: unknown };
|
||||
const content = msg.content;
|
||||
let text = "";
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
text = content
|
||||
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join(" ");
|
||||
}
|
||||
if (text.length > 40) text = text.slice(0, 40) + "…";
|
||||
if (text) return text;
|
||||
if (msg.role === "assistant") return "[assistant]";
|
||||
}
|
||||
return entry.type;
|
||||
}
|
||||
|
||||
// Does the tree have any branching at all?
|
||||
function hasBranch(nodes: SessionTreeNode[]): boolean {
|
||||
for (const node of nodes) {
|
||||
if (node.children.length > 1) return true;
|
||||
if (hasBranch(node.children)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: SessionTreeNode;
|
||||
activePathIds: Set<string>;
|
||||
depth: number;
|
||||
isLast: boolean;
|
||||
parentLines: boolean[]; // whether ancestor at each depth has more siblings after
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
function TreeNodeView({ node, activePathIds, depth, isLast, parentLines, onSelect }: TreeNodeProps) {
|
||||
const { node: rep, skipped } = compress(node);
|
||||
const isActive = activePathIds.has(rep.entry.id);
|
||||
const isOnPath = activePathIds.has(node.entry.id) || activePathIds.has(rep.entry.id);
|
||||
const label = getLabel(rep.entry);
|
||||
const role = rep.entry.type === "message" && "message" in rep.entry
|
||||
? (rep.entry.message as { role: string }).role
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* This node row */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: 24,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => onSelect(rep.entry.id)}
|
||||
>
|
||||
{/* Indent guide lines */}
|
||||
{parentLines.map((hasLine, i) => (
|
||||
<div key={i} style={{ width: 16, flexShrink: 0, position: "relative", height: "100%", alignSelf: "stretch" }}>
|
||||
{hasLine && (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: 7,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 1,
|
||||
background: "var(--border)",
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Branch connector */}
|
||||
<div style={{ width: 16, flexShrink: 0, position: "relative", height: "100%", alignSelf: "stretch" }}>
|
||||
{/* vertical line up (to parent) */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: 7,
|
||||
top: 0,
|
||||
bottom: isLast ? "50%" : 0,
|
||||
width: 1,
|
||||
background: "var(--border)",
|
||||
}} />
|
||||
{/* horizontal line to node */}
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: 7,
|
||||
top: "50%",
|
||||
width: 9,
|
||||
height: 1,
|
||||
background: "var(--border)",
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Node dot */}
|
||||
<div style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
background: isActive ? "var(--accent)" : isOnPath ? "var(--text-muted)" : "var(--border)",
|
||||
border: isActive ? "none" : "1px solid var(--text-dim)",
|
||||
marginRight: 6,
|
||||
transition: "background 0.12s",
|
||||
}} />
|
||||
|
||||
{/* Role badge */}
|
||||
{role && (
|
||||
<span style={{
|
||||
fontSize: 9,
|
||||
fontFamily: "var(--font-mono)",
|
||||
color: role === "user" ? "var(--accent)" : "var(--text-dim)",
|
||||
background: role === "user" ? "rgba(37,99,235,0.08)" : "var(--bg-hover)",
|
||||
border: `1px solid ${role === "user" ? "rgba(37,99,235,0.2)" : "var(--border)"}`,
|
||||
borderRadius: 3,
|
||||
padding: "0 4px",
|
||||
marginRight: 5,
|
||||
flexShrink: 0,
|
||||
lineHeight: "16px",
|
||||
}}>
|
||||
{role === "user" ? "U" : "A"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Skipped indicator */}
|
||||
{skipped > 0 && (
|
||||
<span style={{ fontSize: 10, color: "var(--text-dim)", marginRight: 5, flexShrink: 0 }}>
|
||||
+{skipped}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
color: isActive ? "var(--text)" : isOnPath ? "var(--text-muted)" : "var(--text-dim)",
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{rep.children.map((child, idx) => (
|
||||
<TreeNodeView
|
||||
key={child.entry.id}
|
||||
node={child}
|
||||
activePathIds={activePathIds}
|
||||
depth={depth + 1}
|
||||
isLast={idx === rep.children.length - 1}
|
||||
parentLines={[...parentLines, !isLast]}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchNavigator({ tree, activeLeafId, onLeafChange, inline, containerRef, open: openProp, onToggle, hasSession, compact }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [openInternal, setOpenInternal] = useState(false);
|
||||
const open = openProp !== undefined ? openProp : openInternal;
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !inline) return;
|
||||
const anchor = containerRef?.current ?? btnRef.current;
|
||||
if (!anchor) return;
|
||||
const update = () => {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
setDropdownPos({ top: rect.bottom, left: rect.left, width: rect.width });
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(anchor);
|
||||
return () => ro.disconnect();
|
||||
}, [open, inline, containerRef]);
|
||||
|
||||
const activePathIds = useMemo(
|
||||
() => buildActivePath(tree, activeLeafId),
|
||||
[tree, activeLeafId]
|
||||
);
|
||||
|
||||
const handleSelect = useCallback((id: string) => {
|
||||
onLeafChange(id);
|
||||
}, [onLeafChange]);
|
||||
|
||||
const noBranchReason = !hasSession
|
||||
? t("i18n.noActiveSession")
|
||||
: !hasBranch(tree)
|
||||
? t("i18n.noBranches")
|
||||
: null;
|
||||
|
||||
// Find first meaningful node (skip pure linear prefix)
|
||||
const compressed = tree.length > 0 ? compress(tree[0]) : null;
|
||||
const firstNode = compressed?.node ?? null;
|
||||
const hasContent = !noBranchReason && firstNode && firstNode.children.length > 1;
|
||||
|
||||
const branchIcon = (
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: hasContent ? "var(--accent)" : "var(--text-dim)", flexShrink: 0 }}>
|
||||
<line x1="6" y1="3" x2="6" y2="15" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const chevron = (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--text-dim)" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: 2, transform: open ? "rotate(180deg)" : "none", transition: "transform 0.15s" }}>
|
||||
<polyline points="2 3.5 5 6.5 8 3.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "stretch" }}>
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={() => onToggle ? onToggle() : setOpenInternal((v) => !v)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
height: "100%",
|
||||
padding: "0 12px",
|
||||
background: open ? "var(--bg-selected)" : "none",
|
||||
border: "none",
|
||||
borderTop: open ? "2px solid var(--accent)" : "2px solid transparent",
|
||||
borderRight: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
color: open ? "var(--text)" : "var(--text-muted)",
|
||||
fontSize: 11,
|
||||
whiteSpace: "nowrap",
|
||||
transition: "color 0.1s, background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text)"; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = open ? "var(--text)" : "var(--text-muted)"; }}
|
||||
title={t("i18n.branches")}
|
||||
aria-label={t("i18n.branches")}
|
||||
aria-pressed={open}
|
||||
>
|
||||
{branchIcon}
|
||||
{!compact && <span>{t("i18n.branches")}</span>}
|
||||
</button>
|
||||
{open && dropdownPos && (
|
||||
<div style={{
|
||||
position: "fixed",
|
||||
top: dropdownPos.top,
|
||||
left: dropdownPos.left,
|
||||
width: dropdownPos.width,
|
||||
background: "var(--bg-panel)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
zIndex: 500,
|
||||
}}>
|
||||
{hasContent && firstNode ? (
|
||||
<div style={{ padding: "4px 12px 8px 12px", maxHeight: 260, overflowY: "auto" }}>
|
||||
{firstNode.children.map((child, idx) => (
|
||||
<TreeNodeView
|
||||
key={child.entry.id}
|
||||
node={child}
|
||||
activePathIds={activePathIds}
|
||||
depth={0}
|
||||
isLast={idx === firstNode.children.length - 1}
|
||||
parentLines={[]}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "10px 16px", fontSize: 12, color: "var(--text-muted)", fontStyle: "italic" }}>
|
||||
{noBranchReason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ borderBottom: "1px solid var(--border)", background: "var(--bg)", flexShrink: 0, position: "relative" }}>
|
||||
{/* Header toggle */}
|
||||
<button
|
||||
onClick={() => setOpenInternal((v) => !v)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
width: "100%",
|
||||
padding: "5px 12px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 11,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{branchIcon}
|
||||
<span style={{ color: "var(--text-muted)" }}>{t("i18n.branches")}</span>
|
||||
{chevron}
|
||||
</button>
|
||||
|
||||
{/* Tree panel - overlay */}
|
||||
{open && (
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: "var(--bg)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
|
||||
zIndex: 100,
|
||||
}}>
|
||||
{hasContent && firstNode ? (
|
||||
<div style={{ padding: "4px 12px 8px 12px", maxHeight: 260, overflowY: "auto" }}>
|
||||
{firstNode.children.map((child, idx) => (
|
||||
<TreeNodeView
|
||||
key={child.entry.id}
|
||||
node={child}
|
||||
activePathIds={activePathIds}
|
||||
depth={0}
|
||||
isLast={idx === firstNode.children.length - 1}
|
||||
parentLines={[]}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "10px 16px", fontSize: 12, color: "var(--text-muted)", fontStyle: "italic" }}>
|
||||
{noBranchReason ?? t("i18n.noBranches")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
jsx: { runtime: "automatic" },
|
||||
tsconfigPaths: true,
|
||||
});
|
||||
const { ChatInput, ModelErrorBanner, filterModelOptions } = await jiti.import("./ChatInput.tsx");
|
||||
const { I18nProvider } = await jiti.import("../hooks/useI18n.tsx");
|
||||
|
||||
test("renders the upstream model error", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(ModelErrorBanner, {
|
||||
error: "Invalid models.json schema:\nproviders.custom.models.0.id must not be empty",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(html, /role="alert"/);
|
||||
assert.match(html, /Model error/);
|
||||
assert.match(html, /providers\.custom\.models\.0\.id must not be empty/);
|
||||
});
|
||||
|
||||
test("does not render an empty model error", () => {
|
||||
assert.equal(renderToStaticMarkup(React.createElement(ModelErrorBanner, { error: null })), "");
|
||||
});
|
||||
|
||||
test("keeps the model selector visible when a model error leaves no options", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
null,
|
||||
React.createElement(ChatInput, {
|
||||
onSend() {},
|
||||
onAbort() {},
|
||||
onModelChange() {},
|
||||
isStreaming: false,
|
||||
modelError: "Invalid models.json schema",
|
||||
modelList: [],
|
||||
modelNames: {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(html, />No models</);
|
||||
assert.match(html, /title="No available models"/);
|
||||
});
|
||||
|
||||
test("filters model options by name and id", () => {
|
||||
const options = [
|
||||
{ provider: "ollama", modelId: "qwen3:latest", name: "Qwen 3" },
|
||||
{ provider: "anthropic", modelId: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ provider: "openai", modelId: "gpt-5.4", name: "GPT-5.4" },
|
||||
];
|
||||
|
||||
assert.deepEqual(filterModelOptions(options, "QWEN"), [options[0]]);
|
||||
assert.deepEqual(filterModelOptions(options, "claude-sonnet"), [options[1]]);
|
||||
assert.equal(filterModelOptions(options, "OpenAI").length, 0);
|
||||
assert.equal(filterModelOptions(options, "anthropic/claude").length, 0);
|
||||
assert.equal(filterModelOptions(options, "missing").length, 0);
|
||||
assert.equal(filterModelOptions(options, " "), options);
|
||||
});
|
||||
|
||||
test("renders compact errors above the input as a wrapping alert", () => {
|
||||
const error = "Compaction failed: OpenAI API error (403): <html>request forbidden</html>";
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
null,
|
||||
React.createElement(ChatInput, {
|
||||
onSend() {},
|
||||
onAbort() {},
|
||||
onCompact() {},
|
||||
isStreaming: false,
|
||||
compactError: error,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(html, /role="alert"/);
|
||||
assert.match(html, /Compaction failed: OpenAI API error/);
|
||||
assert.match(html, /<html>request forbidden<\/html>/);
|
||||
assert.match(html, /white-space:pre-wrap/);
|
||||
assert.ok(html.indexOf('role="alert"') < html.indexOf("<textarea"));
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
.preview {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 100%;
|
||||
z-index: 100;
|
||||
width: 320px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
background: var(--bg);
|
||||
border-left: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
||||
box-shadow: -10px 0 26px rgba(0, 0, 0, 0.07);
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.turn {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr);
|
||||
padding: 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 68%, transparent);
|
||||
background: transparent;
|
||||
transition: background 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.turn[data-located="true"] {
|
||||
background: color-mix(in srgb, var(--text) 4%, var(--bg));
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--text-muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.number {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.turn[data-located="true"] .number {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-column: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: block;
|
||||
width: calc(100% + 34px);
|
||||
min-height: 32px;
|
||||
max-height: 86px;
|
||||
margin: 0 0 0 -34px;
|
||||
padding: 7px 10px 7px 40px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
line-height: 18px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.userText {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-clamp: 4;
|
||||
}
|
||||
|
||||
.user:hover {
|
||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||
}
|
||||
|
||||
.assistant {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 52%, transparent);
|
||||
}
|
||||
|
||||
.assistant + .assistant {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.assistantJump {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -29px;
|
||||
z-index: 2;
|
||||
width: 24px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
line-height: 26px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: color 100ms ease, background 100ms ease;
|
||||
}
|
||||
|
||||
.assistantJump:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.outline {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.heading,
|
||||
.paragraph {
|
||||
display: block;
|
||||
width: calc(100% + 34px);
|
||||
min-height: 26px;
|
||||
min-width: 0;
|
||||
margin-left: -34px;
|
||||
padding: 4px 10px 4px 40px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0;
|
||||
line-height: 18px;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease, color 100ms ease;
|
||||
}
|
||||
|
||||
.heading:hover,
|
||||
.paragraph:hover {
|
||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.heading[data-level="1"] {
|
||||
min-height: 32px;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.heading[data-level="2"] {
|
||||
min-height: 28px;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
padding-left: 50px;
|
||||
color: color-mix(in srgb, var(--text) 88%, var(--text-muted));
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.heading[data-level="3"] {
|
||||
padding-left: 60px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.assistant:has(.heading[data-level="1"]:first-child) .assistantJump {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.assistant:has(.heading[data-level="2"]:first-child) .assistantJump {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.user:focus-visible,
|
||||
.heading:focus-visible,
|
||||
.paragraph:focus-visible {
|
||||
outline: 0;
|
||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--text-muted) 65%, transparent);
|
||||
}
|
||||
|
||||
.assistantJump:focus-visible {
|
||||
outline: 1px solid color-mix(in srgb, var(--text-muted) 65%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useRef, useState, useCallback, useMemo, type RefObject } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { markdownPreviewRemarkPlugins } from "@/lib/markdown";
|
||||
import { splitFinalAssistantBlocks } from "@/lib/message-display";
|
||||
import type { AgentMessage, AssistantMessage, TextContent, UserMessage } from "@/lib/types";
|
||||
import styles from "./ChatMinimap.module.css";
|
||||
|
||||
interface Props {
|
||||
messages: AgentMessage[];
|
||||
streamingMessage: Partial<AgentMessage> | null;
|
||||
scrollContainer: RefObject<HTMLDivElement | null>;
|
||||
messageRefs: RefObject<(HTMLDivElement | null)[]>;
|
||||
onRevealHistory: () => void;
|
||||
}
|
||||
|
||||
const MINIMAP_WIDTH = 36;
|
||||
const MAX_NODE_GAP = 50;
|
||||
const MINIMAP_PADDING = 12;
|
||||
const PREVIEW_HIDE_DELAY = 250;
|
||||
const NAVIGATION_ACTIVE_LOCK_MS = 1600;
|
||||
|
||||
interface AssistantPreview {
|
||||
markdown: string;
|
||||
element: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
interface TurnInfo {
|
||||
userMessage: UserMessage;
|
||||
assistantPreviews: AssistantPreview[];
|
||||
scrollTop: number | null;
|
||||
}
|
||||
|
||||
interface NodeInfo {
|
||||
topRatio: number;
|
||||
targetTurn: TurnInfo;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function getUserPreview(message: UserMessage): string {
|
||||
if (typeof message.content === "string") return message.content.trim();
|
||||
return message.content
|
||||
.filter((block): block is TextContent => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getAssistantAnswerMarkdown(message: AgentMessage | Partial<AgentMessage>): string {
|
||||
if (message.role !== "assistant") return "";
|
||||
const { answerBlocks } = splitFinalAssistantBlocks(message as AssistantMessage);
|
||||
return answerBlocks
|
||||
.filter((block): block is TextContent => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function PreviewHeading({
|
||||
level,
|
||||
children,
|
||||
headingIndex,
|
||||
onClick,
|
||||
}: {
|
||||
level: 1 | 2 | 3;
|
||||
children: React.ReactNode;
|
||||
headingIndex: number | null;
|
||||
onClick?: (headingIndex: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.heading}
|
||||
data-level={level}
|
||||
data-preview-heading-index={headingIndex ?? undefined}
|
||||
disabled={headingIndex === null || !onClick}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (headingIndex !== null) onClick?.(headingIndex);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface PreviewAstNode {
|
||||
type?: string;
|
||||
depth?: number;
|
||||
data?: {
|
||||
hProperties?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function remarkPreviewOutline() {
|
||||
return (tree: { children?: PreviewAstNode[] }) => {
|
||||
if (!Array.isArray(tree.children)) return;
|
||||
const headings = tree.children.filter((node) => (
|
||||
node.type === "heading" && typeof node.depth === "number" && node.depth <= 3
|
||||
));
|
||||
if (headings.length > 0) {
|
||||
headings.forEach((node, headingIndex) => {
|
||||
node.data = {
|
||||
...node.data,
|
||||
hProperties: {
|
||||
...node.data?.hProperties,
|
||||
"data-preview-heading-index": headingIndex,
|
||||
},
|
||||
};
|
||||
});
|
||||
tree.children = headings;
|
||||
return;
|
||||
}
|
||||
const firstParagraph = tree.children.find((node) => node.type === "paragraph");
|
||||
tree.children = firstParagraph ? [firstParagraph] : [];
|
||||
};
|
||||
}
|
||||
|
||||
const previewRemarkPlugins = [
|
||||
...(markdownPreviewRemarkPlugins ?? []),
|
||||
remarkPreviewOutline,
|
||||
];
|
||||
|
||||
function getPreviewHeadingIndex(node: unknown): number | null {
|
||||
const properties = (node as { properties?: Record<string, unknown> } | undefined)?.properties;
|
||||
const value = properties?.dataPreviewHeadingIndex ?? properties?.["data-preview-heading-index"];
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
const AssistantOutline = memo(function AssistantOutline({
|
||||
markdown,
|
||||
onHeadingClick,
|
||||
onAnswerClick,
|
||||
}: {
|
||||
markdown: string;
|
||||
onHeadingClick?: (headingIndex: number) => void;
|
||||
onAnswerClick?: () => void;
|
||||
}) {
|
||||
if (!markdown) return null;
|
||||
return (
|
||||
<div className={styles.outline}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={previewRemarkPlugins}
|
||||
components={{
|
||||
h1: ({ children, node }) => <PreviewHeading level={1} headingIndex={getPreviewHeadingIndex(node)} onClick={onHeadingClick}>{children}</PreviewHeading>,
|
||||
h2: ({ children, node }) => <PreviewHeading level={2} headingIndex={getPreviewHeadingIndex(node)} onClick={onHeadingClick}>{children}</PreviewHeading>,
|
||||
h3: ({ children, node }) => <PreviewHeading level={3} headingIndex={getPreviewHeadingIndex(node)} onClick={onHeadingClick}>{children}</PreviewHeading>,
|
||||
h4: () => null,
|
||||
h5: () => null,
|
||||
h6: () => null,
|
||||
p: ({ children }) => (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.paragraph}
|
||||
onClick={onAnswerClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
blockquote: () => null,
|
||||
ul: () => null,
|
||||
ol: () => null,
|
||||
pre: () => null,
|
||||
table: () => null,
|
||||
hr: () => null,
|
||||
a: ({ children }) => <>{children}</>,
|
||||
code: ({ children }) => <>{children}</>,
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function createTurnNodes(turns: TurnInfo[]): NodeInfo[] {
|
||||
return turns.map((turn, index) => ({
|
||||
topRatio: 0,
|
||||
targetTurn: turn,
|
||||
index,
|
||||
}));
|
||||
}
|
||||
|
||||
interface NodeLayout {
|
||||
nodes: NodeInfo[];
|
||||
gap: number;
|
||||
fillsHeight: boolean;
|
||||
}
|
||||
|
||||
function layoutNodes(allNodes: NodeInfo[], minimapHeight: number): NodeLayout {
|
||||
if (allNodes.length === 0) {
|
||||
return { nodes: [], gap: MAX_NODE_GAP, fillsHeight: false };
|
||||
}
|
||||
|
||||
const height = Math.max(1, minimapHeight);
|
||||
const usableHeight = Math.max(0, height - MINIMAP_PADDING * 2);
|
||||
if (allNodes.length === 1) {
|
||||
return {
|
||||
nodes: [{ ...allNodes[0], topRatio: MINIMAP_PADDING / height }],
|
||||
gap: MAX_NODE_GAP,
|
||||
fillsHeight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const naturalGap = usableHeight / (allNodes.length - 1);
|
||||
const gap = Math.min(MAX_NODE_GAP, naturalGap);
|
||||
return {
|
||||
nodes: allNodes.map((node, index) => ({
|
||||
...node,
|
||||
topRatio: (MINIMAP_PADDING + index * gap) / height,
|
||||
})),
|
||||
gap,
|
||||
fillsHeight: naturalGap <= MAX_NODE_GAP,
|
||||
};
|
||||
}
|
||||
|
||||
export function ChatMinimap({
|
||||
messages,
|
||||
streamingMessage,
|
||||
scrollContainer,
|
||||
messageRefs,
|
||||
onRevealHistory,
|
||||
}: Props) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [allNodes, setAllNodes] = useState<NodeInfo[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
const [minimapHeight, setMinimapHeight] = useState(600);
|
||||
const [minimapHovered, setMinimapHovered] = useState(false);
|
||||
const [mouseYRatio, setMouseYRatio] = useState<number | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const allNodesRef = useRef<NodeInfo[]>([]);
|
||||
const nodeLayoutRef = useRef<NodeLayout>({
|
||||
nodes: [],
|
||||
gap: MAX_NODE_GAP,
|
||||
fillsHeight: false,
|
||||
});
|
||||
const previewBoxRef = useRef<HTMLDivElement>(null);
|
||||
const previewItemRefs = useRef(new Map<number, HTMLDivElement>());
|
||||
const previewHideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const activeNodeLockRef = useRef<{ index: number; until: number } | null>(null);
|
||||
const pendingNavigationRef = useRef<{
|
||||
nodeIndex: number;
|
||||
target: "user" | "assistant" | "heading";
|
||||
assistantIndex?: number;
|
||||
headingIndex?: number;
|
||||
} | null>(null);
|
||||
|
||||
const allMessages = useMemo(
|
||||
() => (streamingMessage ? [...messages, streamingMessage] : messages) as (AgentMessage | Partial<AgentMessage>)[],
|
||||
[messages, streamingMessage],
|
||||
);
|
||||
const allMessagesRef = useRef(allMessages);
|
||||
allMessagesRef.current = allMessages;
|
||||
|
||||
const nodeLayout = useMemo(
|
||||
() => layoutNodes(allNodes, minimapHeight),
|
||||
[allNodes, minimapHeight],
|
||||
);
|
||||
const { nodes: positionedNodes, gap: nodeGap } = nodeLayout;
|
||||
nodeLayoutRef.current = nodeLayout;
|
||||
|
||||
const lockActiveNode = useCallback((index: number) => {
|
||||
activeNodeLockRef.current = {
|
||||
index,
|
||||
until: Date.now() + NAVIGATION_ACTIVE_LOCK_MS,
|
||||
};
|
||||
setActiveIndex(index);
|
||||
}, []);
|
||||
|
||||
const syncActiveNode = useCallback((scrollEl: HTMLDivElement, nextNodes: NodeInfo[]) => {
|
||||
const activeLock = activeNodeLockRef.current;
|
||||
if (activeLock && Date.now() < activeLock.until) {
|
||||
setActiveIndex(activeLock.index);
|
||||
return;
|
||||
}
|
||||
activeNodeLockRef.current = null;
|
||||
|
||||
const measuredNodes = nextNodes.filter((node) => node.targetTurn.scrollTop !== null);
|
||||
if (measuredNodes.length === 0) {
|
||||
setActiveIndex(null);
|
||||
return;
|
||||
}
|
||||
const focusTop = scrollEl.scrollTop + scrollEl.clientHeight * 0.3;
|
||||
const nextActiveNode = measuredNodes.reduce((bestNode, node) => (
|
||||
Math.abs((node.targetTurn.scrollTop ?? 0) - focusTop)
|
||||
< Math.abs((bestNode.targetTurn.scrollTop ?? 0) - focusTop)
|
||||
? node
|
||||
: bestNode
|
||||
), measuredNodes[0]);
|
||||
setActiveIndex(nextActiveNode.index);
|
||||
}, []);
|
||||
|
||||
const updateScroll = useCallback(() => {
|
||||
const scrollEl = scrollContainer.current;
|
||||
if (!scrollEl) return;
|
||||
const scrollable = scrollEl.scrollHeight - scrollEl.clientHeight;
|
||||
const currentNodes = allNodesRef.current;
|
||||
setVisible(scrollable > 20);
|
||||
syncActiveNode(scrollEl, currentNodes);
|
||||
}, [scrollContainer, syncActiveNode]);
|
||||
|
||||
const measureThrottleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const measureNodes = useCallback(() => {
|
||||
if (measureThrottleRef.current) return;
|
||||
measureThrottleRef.current = setTimeout(() => {
|
||||
measureThrottleRef.current = null;
|
||||
const scrollEl = scrollContainer.current;
|
||||
const minimapEl = containerRef.current;
|
||||
if (!scrollEl || !minimapEl) return;
|
||||
|
||||
const refs = messageRefs.current;
|
||||
const containerRect = scrollEl.getBoundingClientRect();
|
||||
const turns: TurnInfo[] = [];
|
||||
let refIndex = 0;
|
||||
let currentTurn: TurnInfo | null = null;
|
||||
|
||||
for (const message of allMessagesRef.current) {
|
||||
if (message.role !== "user" && message.role !== "assistant") continue;
|
||||
const element = refs?.[refIndex];
|
||||
refIndex++;
|
||||
|
||||
if (message.role === "user") {
|
||||
currentTurn = null;
|
||||
const elementRect = element?.getBoundingClientRect();
|
||||
currentTurn = {
|
||||
userMessage: message as UserMessage,
|
||||
assistantPreviews: [],
|
||||
scrollTop: elementRect
|
||||
? elementRect.top - containerRect.top + scrollEl.scrollTop
|
||||
: null,
|
||||
};
|
||||
turns.push(currentTurn);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentTurn) continue;
|
||||
const answerMarkdown = getAssistantAnswerMarkdown(message);
|
||||
if (answerMarkdown) {
|
||||
currentTurn.assistantPreviews.push({
|
||||
markdown: answerMarkdown,
|
||||
element,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nextNodes = createTurnNodes(turns);
|
||||
setMinimapHeight(minimapEl.clientHeight);
|
||||
allNodesRef.current = nextNodes;
|
||||
setAllNodes(nextNodes);
|
||||
setVisible(scrollEl.scrollHeight - scrollEl.clientHeight > 20);
|
||||
syncActiveNode(scrollEl, nextNodes);
|
||||
|
||||
const pendingNavigation = pendingNavigationRef.current;
|
||||
const pendingNode = pendingNavigation
|
||||
? nextNodes[pendingNavigation.nodeIndex]
|
||||
: null;
|
||||
if (pendingNavigation && pendingNode) {
|
||||
const assistant = pendingNavigation.assistantIndex === undefined
|
||||
? null
|
||||
: pendingNode.targetTurn.assistantPreviews[pendingNavigation.assistantIndex];
|
||||
let targetTop: number | null = pendingNode.targetTurn.scrollTop;
|
||||
if (pendingNavigation.target === "assistant") {
|
||||
const assistantRect = assistant?.element?.getBoundingClientRect();
|
||||
targetTop = assistantRect
|
||||
? assistantRect.top - containerRect.top + scrollEl.scrollTop
|
||||
: null;
|
||||
} else if (pendingNavigation.target === "heading") {
|
||||
const heading = (
|
||||
pendingNavigation.headingIndex === undefined
|
||||
? null
|
||||
: assistant?.element
|
||||
?.querySelectorAll<HTMLElement>("h1, h2, h3")
|
||||
.item(pendingNavigation.headingIndex)
|
||||
);
|
||||
const headingRect = heading?.getBoundingClientRect();
|
||||
targetTop = headingRect
|
||||
? headingRect.top - containerRect.top + scrollEl.scrollTop
|
||||
: null;
|
||||
}
|
||||
if (targetTop === null) return;
|
||||
pendingNavigationRef.current = null;
|
||||
lockActiveNode(pendingNode.index);
|
||||
const targetOffset = scrollEl.clientHeight * 0.3;
|
||||
scrollEl.scrollTo({ top: Math.max(0, targetTop - targetOffset), behavior: "smooth" });
|
||||
}
|
||||
}, 150);
|
||||
}, [lockActiveNode, messageRefs, scrollContainer, syncActiveNode]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollContainer.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("scroll", updateScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", updateScroll);
|
||||
}, [scrollContainer, updateScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollContainer.current;
|
||||
if (!el) return;
|
||||
const syncLayout = () => {
|
||||
measureNodes();
|
||||
updateScroll();
|
||||
};
|
||||
const ro = new ResizeObserver(syncLayout);
|
||||
ro.observe(el);
|
||||
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
||||
syncLayout();
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
if (measureThrottleRef.current) {
|
||||
clearTimeout(measureThrottleRef.current);
|
||||
measureThrottleRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [measureNodes, scrollContainer, updateScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
measureNodes();
|
||||
updateScroll();
|
||||
}, 50);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [messages.length, measureNodes, updateScroll]);
|
||||
|
||||
const scrollToNode = useCallback((node: NodeInfo, behavior: ScrollBehavior) => {
|
||||
const scrollEl = scrollContainer.current;
|
||||
if (!scrollEl) return;
|
||||
lockActiveNode(node.index);
|
||||
if (node.targetTurn.scrollTop === null) {
|
||||
pendingNavigationRef.current = { nodeIndex: node.index, target: "user" };
|
||||
onRevealHistory();
|
||||
return;
|
||||
}
|
||||
const targetTop = Math.max(
|
||||
0,
|
||||
node.targetTurn.scrollTop - scrollEl.clientHeight * 0.3,
|
||||
);
|
||||
scrollEl.scrollTo({ top: targetTop, behavior });
|
||||
}, [lockActiveNode, onRevealHistory, scrollContainer]);
|
||||
|
||||
const scrollToAssistant = useCallback((node: NodeInfo, assistantIndex: number) => {
|
||||
const scrollEl = scrollContainer.current;
|
||||
if (!scrollEl) return;
|
||||
const assistantElement = node.targetTurn.assistantPreviews[assistantIndex]?.element;
|
||||
if (!assistantElement) {
|
||||
pendingNavigationRef.current = {
|
||||
nodeIndex: node.index,
|
||||
target: "assistant",
|
||||
assistantIndex,
|
||||
};
|
||||
onRevealHistory();
|
||||
return;
|
||||
}
|
||||
const containerRect = scrollEl.getBoundingClientRect();
|
||||
const assistantRect = assistantElement.getBoundingClientRect();
|
||||
const targetTop = (
|
||||
assistantRect.top
|
||||
- containerRect.top
|
||||
+ scrollEl.scrollTop
|
||||
- scrollEl.clientHeight * 0.3
|
||||
);
|
||||
lockActiveNode(node.index);
|
||||
scrollEl.scrollTo({ top: Math.max(0, targetTop), behavior: "smooth" });
|
||||
}, [lockActiveNode, onRevealHistory, scrollContainer]);
|
||||
|
||||
const findNearestNode = useCallback((ratio: number): NodeInfo | null => {
|
||||
const { nodes, gap, fillsHeight } = nodeLayoutRef.current;
|
||||
const height = containerRef.current?.clientHeight ?? 0;
|
||||
if (nodes.length === 0 || height <= 0) return null;
|
||||
|
||||
const pointerY = Math.max(0, Math.min(height, ratio * height));
|
||||
const firstNodeY = nodes[0].topRatio * height;
|
||||
const rawIndex = gap > 0 ? Math.round((pointerY - firstNodeY) / gap) : 0;
|
||||
const nodeIndex = Math.max(0, Math.min(nodes.length - 1, rawIndex));
|
||||
const nearestNode = nodes[nodeIndex];
|
||||
|
||||
if (!fillsHeight) {
|
||||
const nodeY = nearestNode.topRatio * height;
|
||||
const hitRadius = Math.max(10, gap / 2);
|
||||
if (Math.abs(pointerY - nodeY) > hitRadius) return null;
|
||||
}
|
||||
return nearestNode;
|
||||
}, []);
|
||||
|
||||
const scrollToHeading = useCallback((
|
||||
node: NodeInfo,
|
||||
assistantIndex: number,
|
||||
headingIndex: number,
|
||||
) => {
|
||||
const scrollEl = scrollContainer.current;
|
||||
if (!scrollEl) return;
|
||||
const answerElement = node.targetTurn.assistantPreviews[assistantIndex]?.element;
|
||||
if (!answerElement) {
|
||||
pendingNavigationRef.current = {
|
||||
nodeIndex: node.index,
|
||||
target: "heading",
|
||||
assistantIndex,
|
||||
headingIndex,
|
||||
};
|
||||
onRevealHistory();
|
||||
return;
|
||||
}
|
||||
const heading = answerElement.querySelectorAll<HTMLElement>("h1, h2, h3").item(headingIndex);
|
||||
if (!heading) return;
|
||||
const containerRect = scrollEl.getBoundingClientRect();
|
||||
const headingRect = heading.getBoundingClientRect();
|
||||
const targetTop = (
|
||||
headingRect.top
|
||||
- containerRect.top
|
||||
+ scrollEl.scrollTop
|
||||
- scrollEl.clientHeight * 0.3
|
||||
);
|
||||
lockActiveNode(node.index);
|
||||
scrollEl.scrollTo({ top: Math.max(0, targetTop), behavior: "smooth" });
|
||||
}, [lockActiveNode, onRevealHistory, scrollContainer]);
|
||||
|
||||
const cancelPreviewHide = useCallback(() => {
|
||||
if (!previewHideTimerRef.current) return;
|
||||
clearTimeout(previewHideTimerRef.current);
|
||||
previewHideTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const showPreview = useCallback(() => {
|
||||
cancelPreviewHide();
|
||||
setMinimapHovered(true);
|
||||
}, [cancelPreviewHide]);
|
||||
|
||||
const schedulePreviewHide = useCallback(() => {
|
||||
cancelPreviewHide();
|
||||
previewHideTimerRef.current = setTimeout(() => {
|
||||
previewHideTimerRef.current = null;
|
||||
setMinimapHovered(false);
|
||||
setMouseYRatio(null);
|
||||
}, PREVIEW_HIDE_DELAY);
|
||||
}, [cancelPreviewHide]);
|
||||
|
||||
useEffect(() => () => cancelPreviewHide(), [cancelPreviewHide]);
|
||||
|
||||
const handleMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!visible) return;
|
||||
|
||||
draggingRef.current = true;
|
||||
showPreview();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const pointerRatio = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
|
||||
setMouseYRatio(pointerRatio);
|
||||
const jumpToPointer = (clientY: number, behavior: ScrollBehavior) => {
|
||||
const ratio = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||
const node = findNearestNode(ratio);
|
||||
if (node) {
|
||||
scrollToNode(node, behavior);
|
||||
}
|
||||
};
|
||||
|
||||
jumpToPointer(event.clientY, "smooth");
|
||||
const onMove = (moveEvent: MouseEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
jumpToPointer(moveEvent.clientY, "auto");
|
||||
};
|
||||
const onUp = () => {
|
||||
draggingRef.current = false;
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
}, [findNearestNode, scrollToNode, showPreview, visible]);
|
||||
|
||||
const nearestNode = mouseYRatio === null ? null : findNearestNode(mouseYRatio);
|
||||
const nearestNodeIndex = nearestNode?.index ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!minimapHovered || nearestNodeIndex === null) return;
|
||||
const previewBox = previewBoxRef.current;
|
||||
const previewItem = previewItemRefs.current.get(nearestNodeIndex);
|
||||
if (!previewBox || !previewItem) return;
|
||||
const targetTop = previewItem.offsetTop
|
||||
- (previewBox.clientHeight - previewItem.offsetHeight) / 2;
|
||||
previewBox.scrollTop = Math.max(0, targetTop);
|
||||
}, [allNodes, minimapHovered, nearestNodeIndex]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const lastNodeTop = positionedNodes.length > 0
|
||||
? positionedNodes[positionedNodes.length - 1].topRatio * minimapHeight
|
||||
: MINIMAP_PADDING;
|
||||
const railHeight = Math.max(1, lastNodeTop - MINIMAP_PADDING);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseEnter={showPreview}
|
||||
onMouseLeave={schedulePreviewHide}
|
||||
onMouseMove={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMouseYRatio((event.clientY - rect.top) / rect.height);
|
||||
}}
|
||||
style={{
|
||||
width: MINIMAP_WIDTH,
|
||||
flexShrink: 0,
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
borderLeft: "1px solid var(--border)",
|
||||
background: "var(--bg-panel)",
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: MINIMAP_PADDING,
|
||||
height: railHeight,
|
||||
width: 1,
|
||||
background: "var(--border)",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{positionedNodes.map((node) => {
|
||||
const isNearest = minimapHovered && nearestNode?.index === node.index;
|
||||
const isActive = activeIndex === node.index;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.index}
|
||||
data-minimap-node-index={node.index}
|
||||
data-minimap-node-active={isActive ? "" : undefined}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${node.topRatio * 100}%`,
|
||||
transform: "translateY(-50%)",
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: Math.max(1, nodeGap),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: isActive ? "rgba(128,128,128,0.42)" : "rgba(128,128,128,0.16)",
|
||||
border: `1.5px solid ${isActive ? "rgba(128,128,128,0.95)" : "rgba(128,128,128,0.58)"}`,
|
||||
boxShadow: isActive ? "0 0 0 2px var(--bg-panel)" : "none",
|
||||
transition: "transform 0.1s, background 0.1s",
|
||||
transform: isNearest ? "scale(1.25)" : "scale(1)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{minimapHovered && allNodes.length > 0 && (
|
||||
<div
|
||||
ref={previewBoxRef}
|
||||
className={styles.preview}
|
||||
data-minimap-preview-box=""
|
||||
onMouseEnter={showPreview}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onMouseMove={(event) => event.stopPropagation()}
|
||||
>
|
||||
{allNodes.map((node) => {
|
||||
const isLocated = nearestNodeIndex === node.index;
|
||||
return (
|
||||
<div
|
||||
key={node.index}
|
||||
ref={(element) => {
|
||||
if (element) previewItemRefs.current.set(node.index, element);
|
||||
else previewItemRefs.current.delete(node.index);
|
||||
}}
|
||||
className={styles.turn}
|
||||
data-minimap-preview-index={node.index}
|
||||
data-located={isLocated ? "true" : undefined}
|
||||
>
|
||||
<span className={styles.number} aria-hidden="true">
|
||||
{String(node.index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<div className={styles.content}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.user}
|
||||
data-minimap-preview-user={node.index}
|
||||
onClick={() => {
|
||||
scrollToNode(node, "smooth");
|
||||
}}
|
||||
>
|
||||
<span className={styles.userText}>
|
||||
{getUserPreview(node.targetTurn.userMessage)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{node.targetTurn.assistantPreviews.map((assistant, assistantIndex) => (
|
||||
<div
|
||||
key={assistantIndex}
|
||||
className={styles.assistant}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.assistantJump}
|
||||
data-minimap-preview-assistant={`${node.index}-${assistantIndex}`}
|
||||
onClick={() => scrollToAssistant(node, assistantIndex)}
|
||||
aria-label="Locate assistant message"
|
||||
title="Locate assistant message"
|
||||
>
|
||||
A
|
||||
</button>
|
||||
<AssistantOutline
|
||||
markdown={assistant.markdown}
|
||||
onAnswerClick={() => scrollToAssistant(node, assistantIndex)}
|
||||
onHeadingClick={(headingIndex) => (
|
||||
scrollToHeading(node, assistantIndex, headingIndex)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook to create a stable array of refs for messages
|
||||
export function useMessageRefs(count: number): RefObject<(HTMLDivElement | null)[]> {
|
||||
const refs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
refs.current = Array(count).fill(null).map((_, i) => refs.current[i] ?? null);
|
||||
return refs;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface BrowseResponse {
|
||||
path?: string;
|
||||
parentPath?: string | null;
|
||||
directories?: DirectoryEntry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function loadDirectories(directory?: string): Promise<BrowseResponse> {
|
||||
const query = directory ? `?path=${encodeURIComponent(directory)}` : "";
|
||||
const response = await fetch(`/api/cwd/browse${query}`);
|
||||
const data = await response.json() as BrowseResponse;
|
||||
if (!response.ok || data.error) throw new Error(data.error ?? `HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function FolderIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden="true">
|
||||
<path d="M1.5 3h4l1.5 2h7.5v7.5h-13z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onSelect: (path: string) => void;
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function DirectoryPicker({ onCancel, onSelect, busy = false, error }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState("");
|
||||
const [parentDirectory, setParentDirectory] = useState<string | null>(null);
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const navigateTo = useCallback(async (directory?: string) => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const data = await loadDirectories(directory);
|
||||
const nextPath = data.path ?? directory ?? "/";
|
||||
setCurrentPath(nextPath);
|
||||
setParentDirectory(data.parentPath ?? null);
|
||||
setPathInput(nextPath);
|
||||
setDirectories(data.directories ?? []);
|
||||
} catch (cause) {
|
||||
setLoadError(cause instanceof Error ? cause.message : String(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPortalTarget(document.body);
|
||||
void navigateTo();
|
||||
}, [navigateTo]);
|
||||
|
||||
const handlePathSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const candidate = pathInput.trim();
|
||||
if (candidate) void navigateTo(candidate);
|
||||
};
|
||||
const hasUncommittedPath = pathInput.trim() !== currentPath;
|
||||
const canSelect = Boolean(currentPath) && !hasUncommittedPath && !busy;
|
||||
|
||||
if (!portalTarget) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="directory-picker-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("directoryPicker.selectDirectory")}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !busy) onCancel();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && !busy) onCancel();
|
||||
}}
|
||||
style={{ position: "fixed", inset: 0, zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,0.35)" }}
|
||||
>
|
||||
<div className="directory-picker-panel" style={{ width: 520, maxWidth: "calc(100vw - 16px)", height: "min(620px, calc(100dvh - 16px))", maxHeight: "calc(100dvh - 16px)", display: "flex", flexDirection: "column", overflow: "hidden", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 10, boxShadow: "0 8px 32px rgba(0,0,0,0.18)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0, padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ color: "var(--text)", fontWeight: 700, fontSize: 15 }}>{t("directoryPicker.selectDirectory")}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
title={t("i18n.close")}
|
||||
aria-label={t("i18n.close")}
|
||||
style={{ padding: "2px 6px", border: 0, background: "none", color: "var(--text-muted)", fontSize: 20, lineHeight: 1, cursor: busy ? "default" : "pointer", opacity: busy ? 0.5 : 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePathSubmit} style={{ display: "flex", alignItems: "center", gap: 8, flexShrink: 0, padding: "10px 14px", borderBottom: "1px solid var(--border)" }}>
|
||||
<button className="directory-picker-back" type="button" onClick={() => parentDirectory && void navigateTo(parentDirectory)} disabled={loading || !parentDirectory} title={t("directoryPicker.goToParent")} aria-label={t("directoryPicker.goToParent")} style={{ width: 36, height: 36, padding: 0, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, border: "1px solid var(--border)", borderRadius: 6, background: "var(--bg-hover)", color: "var(--text-muted)", cursor: parentDirectory ? "pointer" : "default", opacity: parentDirectory ? 1 : 0.45 }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<label htmlFor="directory-path" style={{ position: "absolute", width: 1, height: 1, padding: 0, margin: -1, overflow: "hidden", clip: "rect(0, 0, 0, 0)", whiteSpace: "nowrap", border: 0 }}>
|
||||
{t("directoryPicker.directoryPath")}
|
||||
</label>
|
||||
<input
|
||||
className="directory-picker-path"
|
||||
id="directory-path"
|
||||
type="text"
|
||||
value={pathInput}
|
||||
placeholder="/path/to/project or ~/project"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setPathInput(event.target.value);
|
||||
setLoadError(null);
|
||||
}}
|
||||
style={{ minWidth: 0, flex: 1, height: 36, padding: "0 10px", border: "1px solid var(--border)", borderRadius: 6, outline: "none", background: "var(--bg-panel)", color: "var(--text)", fontFamily: "var(--font-mono)", fontSize: 12 }}
|
||||
/>
|
||||
<button
|
||||
className="directory-picker-action"
|
||||
type="submit"
|
||||
disabled={loading || !pathInput.trim()}
|
||||
title={t("directoryPicker.goToDirectory")}
|
||||
style={{ minWidth: 58, height: 36, padding: "0 12px", border: "1px solid var(--border)", borderRadius: 6, background: "var(--bg-hover)", color: "var(--text-muted)", cursor: loading || !pathInput.trim() ? "default" : "pointer", opacity: loading || !pathInput.trim() ? 0.6 : 1 }}
|
||||
>
|
||||
{t("directoryPicker.go")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="directory-picker-list" style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "8px 10px" }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 8, color: "var(--text-dim)", fontSize: 11 }}>{t("directoryPicker.loadingDirectories")}</div>
|
||||
) : directories.length > 0 ? (
|
||||
directories.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
className="directory-picker-entry"
|
||||
type="button"
|
||||
onClick={() => void navigateTo(entry.path)}
|
||||
title={entry.path}
|
||||
style={{ width: "100%", minHeight: 30, display: "flex", alignItems: "center", gap: 7, padding: "5px 8px", border: 0, borderRadius: 5, background: "none", color: "var(--text-muted)", cursor: "pointer", textAlign: "left", fontFamily: "var(--font-mono)", fontSize: 11 }}
|
||||
>
|
||||
<FolderIcon />
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{entry.name}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div style={{ padding: 8, color: "var(--text-dim)", fontSize: 11 }}>{t("directoryPicker.noSubdirectories")}</div>
|
||||
)}
|
||||
{(loadError || error) && <div style={{ padding: "8px", color: "#dc2626", fontSize: 11 }}>{loadError ?? error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="directory-picker-footer" style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 10, flexShrink: 0, padding: "10px 18px", borderTop: "1px solid var(--border)" }}>
|
||||
<button className="directory-picker-action" type="button" onClick={onCancel} disabled={busy} style={{ padding: "6px 14px", border: "1px solid var(--border)", borderRadius: 6, background: "none", color: "var(--text-muted)", cursor: busy ? "default" : "pointer", fontSize: 13 }}>{t("i18n.cancel")}</button>
|
||||
<button
|
||||
className="directory-picker-action"
|
||||
type="button"
|
||||
onClick={() => onSelect(currentPath)}
|
||||
disabled={!canSelect}
|
||||
title={hasUncommittedPath ? t("directoryPicker.openBeforeSelecting") : t("directoryPicker.selectCurrentDirectory")}
|
||||
style={{ padding: "6px 16px", border: 0, borderRadius: 6, background: "var(--accent)", color: "#fff", fontSize: 13, fontWeight: 600, opacity: canSelect ? 1 : 0.6, cursor: canSelect ? "pointer" : "default" }}
|
||||
>
|
||||
{busy ? t("i18n.checking") : t("directoryPicker.selectThisFolder")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
portalTarget,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
jsx: { runtime: "automatic" },
|
||||
tsconfigPaths: true,
|
||||
});
|
||||
const {
|
||||
ExtensionStatusBar,
|
||||
formatExtensionStatusLine,
|
||||
sanitizeExtensionStatusText,
|
||||
} = await jiti.import("./ExtensionStatusBar.tsx");
|
||||
|
||||
test("sorts status text by hidden key like the Pi CLI footer", () => {
|
||||
const statuses = [
|
||||
{ key: "20-memory", text: "memory" },
|
||||
{ key: "90-notify", text: "notify" },
|
||||
{ key: "10-permissions", text: "permissions" },
|
||||
{ key: "05-ponytail", text: "ponytail" },
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
formatExtensionStatusLine(statuses),
|
||||
"ponytail permissions memory notify",
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizes status text for a single-line display", () => {
|
||||
assert.equal(
|
||||
sanitizeExtensionStatusText(" first\tsecond \r\n third "),
|
||||
"first second third",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders a single status line without identifier keys", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(ExtensionStatusBar, {
|
||||
statuses: [
|
||||
{ key: "20-memory", text: "\x1b[32mmemory\x1b[0m" },
|
||||
{ key: "05-ponytail", text: "ponytail" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(html, /aria-label="ponytail memory"/);
|
||||
assert.match(html, /height:36px/);
|
||||
assert.match(html, /border-top:1px solid var\(--border\)/);
|
||||
assert.match(html, /background:transparent/);
|
||||
assert.match(html, /font-family:var\(--font-mono\)/);
|
||||
assert.match(html, />ponytail <\/span>/);
|
||||
assert.match(html, />memory</);
|
||||
assert.doesNotMatch(html, /05-ponytail|20-memory/);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { parseAnsiLine, stripAnsi } from "@/lib/ansi";
|
||||
import type { ExtensionStatusItem } from "@/lib/types";
|
||||
|
||||
export function sanitizeExtensionStatusText(text: string): string {
|
||||
return text
|
||||
.replace(/[\r\n\t]/g, " ")
|
||||
.replace(/ +/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function formatExtensionStatusLine(statuses: ExtensionStatusItem[]): string {
|
||||
return [...statuses]
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
.map(({ text }) => sanitizeExtensionStatusText(text))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function ExtensionStatusBar({ statuses }: { statuses: ExtensionStatusItem[] }) {
|
||||
if (statuses.length === 0) return null;
|
||||
|
||||
const statusLine = formatExtensionStatusLine(statuses);
|
||||
const plainStatusLine = stripAnsi(statusLine);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={plainStatusLine}
|
||||
title={plainStatusLine}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexShrink: 0,
|
||||
minWidth: 0,
|
||||
height: 36,
|
||||
padding: "0 12px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{parseAnsiLine(statusLine).map((segment, index) => (
|
||||
<span key={index} style={segment.style}>{segment.text}</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useState, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import { getFileIcon, FolderIcon } from "./FileIcons";
|
||||
import {
|
||||
encodeFilePathForApi,
|
||||
getFileDirectory,
|
||||
getFileName,
|
||||
getRelativeFilePath,
|
||||
joinFilePath,
|
||||
normalizeFilePathSlashes,
|
||||
} from "@/lib/file-paths";
|
||||
import type { GitFileStatus, GitFileStatusKind, GitStatusResponse } from "@/lib/git-types";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
type Translate = ReturnType<typeof useI18n>["t"];
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
children?: FileNode[];
|
||||
loaded?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cwd: string;
|
||||
onOpenFile: (filePath: string, fileName: string, options?: OpenFileOptions) => void;
|
||||
refreshKey?: number;
|
||||
onAtMention?: (relativePath: string, isDir: boolean) => void;
|
||||
onAtMentions?: (relativePaths: string[]) => void;
|
||||
onUploadBusyChange?: (busy: boolean) => void;
|
||||
changesCollapsed: boolean;
|
||||
onChangesCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export interface FileExplorerHandle {
|
||||
openUploadPicker: () => void;
|
||||
}
|
||||
|
||||
type UploadPhase = "idle" | "checking" | "uploading";
|
||||
type UploadConflictStrategy = "error" | "overwrite" | "skip";
|
||||
|
||||
interface UploadError {
|
||||
name: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
uploaded?: string[];
|
||||
skipped?: string[];
|
||||
errors?: UploadError[];
|
||||
conflicts?: string[];
|
||||
nonReplaceable?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface UploadSummary {
|
||||
uploaded: string[];
|
||||
skipped: string[];
|
||||
errors: UploadError[];
|
||||
}
|
||||
|
||||
interface PendingConflict {
|
||||
files: File[];
|
||||
conflicts: string[];
|
||||
nonReplaceable: string[];
|
||||
}
|
||||
|
||||
async function fetchEntries(dirPath: string): Promise<FileNode[]> {
|
||||
const encoded = encodeFilePathForApi(dirPath);
|
||||
const res = await fetch(`/api/files/${encoded}?type=list`);
|
||||
if (!res.ok) {
|
||||
let message = `Failed to load files (HTTP ${res.status})`;
|
||||
try {
|
||||
const data = await res.json() as { error?: string };
|
||||
if (data.error) message = data.error;
|
||||
} catch {
|
||||
// ignore non-JSON error bodies
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const data = await res.json() as { entries?: FileEntry[] };
|
||||
return (data.entries ?? []).map((e) => ({
|
||||
name: e.name,
|
||||
fullPath: joinFilePath(dirPath, e.name),
|
||||
isDir: e.isDir,
|
||||
size: e.size,
|
||||
children: e.isDir ? [] : undefined,
|
||||
loaded: !e.isDir,
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchGitStatus(cwd: string): Promise<GitStatusResponse> {
|
||||
const params = new URLSearchParams({ cwd });
|
||||
const res = await fetch(`/api/git/status?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`Failed to load Git status (HTTP ${res.status})`);
|
||||
return res.json() as Promise<GitStatusResponse>;
|
||||
}
|
||||
|
||||
const GIT_STATUS_KEYS: Record<GitFileStatusKind, string> = {
|
||||
modified: "files.modified",
|
||||
added: "files.added",
|
||||
deleted: "files.deleted",
|
||||
renamed: "files.renamed",
|
||||
untracked: "files.untracked",
|
||||
conflict: "files.conflict",
|
||||
};
|
||||
|
||||
const GIT_STATUS_COLORS: Record<GitFileStatusKind, string> = {
|
||||
modified: "#d6a84b",
|
||||
added: "#4ade80",
|
||||
deleted: "#f87171",
|
||||
renamed: "#60a5fa",
|
||||
untracked: "#4ade80",
|
||||
conflict: "#f87171",
|
||||
};
|
||||
|
||||
function GitStatusBadge({ status, t }: { status: GitFileStatus; t: Translate }) {
|
||||
return (
|
||||
<span
|
||||
title={t(GIT_STATUS_KEYS[status.status])}
|
||||
aria-label={t(GIT_STATUS_KEYS[status.status])}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: GIT_STATUS_COLORS[status.status],
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{status.code}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function uploadFiles(
|
||||
targetDirectory: string,
|
||||
files: File[],
|
||||
strategy: UploadConflictStrategy,
|
||||
onProgress: (progress: number) => void,
|
||||
): Promise<{ status: number; data: UploadResponse }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => formData.append("files", file, file.name));
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(
|
||||
"POST",
|
||||
`/api/files/${encodeFilePathForApi(targetDirectory)}?type=upload&conflict=${strategy}`,
|
||||
);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
onProgress(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("Network error while uploading files"));
|
||||
xhr.onabort = () => reject(new Error("Upload cancelled"));
|
||||
xhr.onload = () => {
|
||||
let data: UploadResponse = {};
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText) as UploadResponse;
|
||||
} catch {
|
||||
if (xhr.responseText) data.error = xhr.responseText;
|
||||
}
|
||||
resolve({ status: xhr.status, data });
|
||||
};
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function MentionIcon({ size = 11 }: { size?: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DismissButton({ onClick, title }: { onClick: () => void; title: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
style={{ width: 24, height: 24, padding: 0, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, border: "none", borderRadius: 4, background: "none", color: "var(--text-dim)", cursor: "pointer" }}
|
||||
onMouseEnter={(event) => { event.currentTarget.style.color = "var(--text-muted)"; event.currentTarget.style.background = "var(--bg-hover)"; }}
|
||||
onMouseLeave={(event) => { event.currentTarget.style.color = "var(--text-dim)"; event.currentTarget.style.background = "none"; }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="m6 6 12 12" />
|
||||
<path d="m18 6-12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
depth,
|
||||
cwd,
|
||||
onOpenFile,
|
||||
onAtMention,
|
||||
expandedPaths,
|
||||
onToggleExpanded,
|
||||
refreshToken,
|
||||
highlightedPaths,
|
||||
gitStatusByPath,
|
||||
changedDirectoryPaths,
|
||||
t,
|
||||
}: {
|
||||
node: FileNode;
|
||||
depth: number;
|
||||
cwd: string;
|
||||
onOpenFile: (filePath: string, fileName: string, options?: OpenFileOptions) => void;
|
||||
onAtMention?: (relativePath: string, isDir: boolean) => void;
|
||||
expandedPaths: Set<string>;
|
||||
onToggleExpanded: (fullPath: string, open: boolean) => void;
|
||||
refreshToken: string;
|
||||
highlightedPaths: Set<string>;
|
||||
gitStatusByPath: Map<string, GitFileStatus>;
|
||||
changedDirectoryPaths: Set<string>;
|
||||
t: Translate;
|
||||
}) {
|
||||
const open = expandedPaths.has(node.fullPath);
|
||||
const highlighted = highlightedPaths.has(node.fullPath);
|
||||
const normalizedPath = normalizeFilePathSlashes(node.fullPath);
|
||||
const gitStatus = gitStatusByPath.get(normalizedPath);
|
||||
const containsGitChanges = node.isDir && (
|
||||
gitStatus !== undefined || changedDirectoryPaths.has(normalizedPath)
|
||||
);
|
||||
const [children, setChildren] = useState<FileNode[]>(node.children ?? []);
|
||||
const [loaded, setLoaded] = useState(node.loaded ?? false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const loadChildren = useCallback(async (force = false) => {
|
||||
if (loaded && !force) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const entries = await fetchEntries(node.fullPath);
|
||||
setChildren(entries);
|
||||
setLoaded(true);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loaded, node.fullPath]);
|
||||
|
||||
// Re-fetch children when the tree refreshes and the directory is open.
|
||||
useEffect(() => {
|
||||
if (open && loaded) {
|
||||
loadChildren(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshToken]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (node.isDir) {
|
||||
const next = !open;
|
||||
onToggleExpanded(node.fullPath, next);
|
||||
if (next && !loaded) loadChildren();
|
||||
} else {
|
||||
onOpenFile(node.fullPath, node.name);
|
||||
}
|
||||
}, [node.isDir, node.fullPath, node.name, loaded, open, loadChildren, onOpenFile, onToggleExpanded]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingLeft: 8 + depth * 14,
|
||||
paddingRight: 8,
|
||||
height: 24,
|
||||
cursor: "pointer",
|
||||
background: hovered ? "var(--bg-hover)" : "transparent",
|
||||
borderRadius: 4,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{node.isDir && (
|
||||
<svg
|
||||
width="10" height="10" viewBox="0 0 10 10" fill="none"
|
||||
stroke="var(--text-dim)" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
|
||||
style={{ flexShrink: 0, transform: open ? "rotate(90deg)" : "none", transition: "transform 0.1s" }}
|
||||
>
|
||||
<polyline points="3 2 7 5 3 8" />
|
||||
</svg>
|
||||
)}
|
||||
{!node.isDir && <span style={{ width: 10, flexShrink: 0 }} />}
|
||||
<span style={{ flexShrink: 0, display: "flex", alignItems: "center" }}>
|
||||
{node.isDir ? <FolderIcon size={14} open={open} /> : getFileIcon(node.name, 14)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
}}
|
||||
title={node.fullPath}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
{highlighted && (
|
||||
<span
|
||||
title={t("files.newlyUploaded")}
|
||||
aria-label={t("files.newlyUploaded")}
|
||||
style={{ width: 14, height: 14, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#3b82f6" }} />
|
||||
</span>
|
||||
)}
|
||||
{!hovered && !node.isDir && gitStatus && (
|
||||
<GitStatusBadge status={gitStatus} t={t} />
|
||||
)}
|
||||
{!hovered && containsGitChanges && (
|
||||
<span
|
||||
title={t("files.containsChangedFiles")}
|
||||
aria-label={t("files.containsChangedFiles")}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#d6a84b" }} />
|
||||
</span>
|
||||
)}
|
||||
{loading && (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
)}
|
||||
{onAtMention && hovered && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAtMention(getRelativeFilePath(node.fullPath, cwd), node.isDir);
|
||||
}}
|
||||
title={t("files.insertPath")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: !node.isDir ? 28 : 4,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 4,
|
||||
padding: "0 8px",
|
||||
height: 20,
|
||||
background: "var(--bg-panel)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 4,
|
||||
color: "var(--accent)",
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<MentionIcon />
|
||||
{t("files.mention")}
|
||||
</button>
|
||||
)}
|
||||
{hovered && !node.isDir && (
|
||||
<a
|
||||
href={`/api/files/${encodeFilePathForApi(node.fullPath)}?type=download`}
|
||||
download
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={t("files.download")}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 4,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 4,
|
||||
padding: "0 5px",
|
||||
height: 20,
|
||||
background: "var(--bg-panel)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 4,
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{node.isDir && open && (
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.fullPath}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
cwd={cwd}
|
||||
onOpenFile={onOpenFile}
|
||||
onAtMention={onAtMention}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
refreshToken={refreshToken}
|
||||
highlightedPaths={highlightedPaths}
|
||||
gitStatusByPath={gitStatusByPath}
|
||||
changedDirectoryPaths={changedDirectoryPaths}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{children.length === 0 && loaded && (
|
||||
<div style={{ paddingLeft: 8 + (depth + 1) * 14, fontSize: 11, color: "var(--text-dim)", height: 22, display: "flex", alignItems: "center" }}>
|
||||
empty
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OpenFileOptions = { sourceSessionId?: string | null; modeHint?: "diff" };
|
||||
|
||||
type OpenFileHandler = (filePath: string, fileName: string, options?: OpenFileOptions) => void;
|
||||
|
||||
function ChangeRow({
|
||||
status,
|
||||
cwd,
|
||||
onOpenFile,
|
||||
t,
|
||||
}: {
|
||||
status: GitFileStatus;
|
||||
cwd: string;
|
||||
onOpenFile: OpenFileHandler;
|
||||
t: Translate;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const name = getFileName(status.filePath);
|
||||
const rel = getRelativeFilePath(status.filePath, cwd);
|
||||
return (
|
||||
<div
|
||||
onClick={() => onOpenFile(status.filePath, name, { modeHint: "diff" })}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
title={status.filePath}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 8,
|
||||
height: 24,
|
||||
cursor: "pointer",
|
||||
background: hovered ? "var(--bg-hover)" : "transparent",
|
||||
borderRadius: 4,
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
<GitStatusBadge status={status} t={t} />
|
||||
<span style={{ flexShrink: 0, display: "flex", alignItems: "center", opacity: 0.85 }}>
|
||||
{getFileIcon(name, 13)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{rel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const FileExplorer = forwardRef<FileExplorerHandle, Props>(function FileExplorer({
|
||||
cwd,
|
||||
onOpenFile,
|
||||
refreshKey,
|
||||
onAtMention,
|
||||
onAtMentions,
|
||||
onUploadBusyChange,
|
||||
changesCollapsed,
|
||||
onChangesCountChange,
|
||||
}, ref) {
|
||||
const { t } = useI18n();
|
||||
const [roots, setRoots] = useState<FileNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [treeRefreshKey, setTreeRefreshKey] = useState(0);
|
||||
const [highlightedPaths, setHighlightedPaths] = useState<Set<string>>(new Set());
|
||||
const [gitFiles, setGitFiles] = useState<GitFileStatus[]>([]);
|
||||
const [gitLineStats, setGitLineStats] = useState({ additions: 0, deletions: 0 });
|
||||
const [uploadPhase, setUploadPhase] = useState<UploadPhase>("idle");
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploadSummary, setUploadSummary] = useState<UploadSummary | null>(null);
|
||||
const [pendingConflict, setPendingConflict] = useState<PendingConflict | null>(null);
|
||||
const prevCwdRef = useRef<string | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const refreshToken = `${refreshKey ?? 0}:${treeRefreshKey}`;
|
||||
const uploadBusy = uploadPhase !== "idle";
|
||||
|
||||
const gitStatusByPath = useMemo(() => new Map(
|
||||
gitFiles.map((status) => [normalizeFilePathSlashes(status.filePath), status]),
|
||||
), [gitFiles]);
|
||||
|
||||
const changedDirectoryPaths = useMemo(() => {
|
||||
const directories = new Set<string>();
|
||||
const normalizedCwd = normalizeFilePathSlashes(cwd).replace(/\/$/, "");
|
||||
for (const status of gitFiles) {
|
||||
let directory = getFileDirectory(normalizeFilePathSlashes(status.filePath));
|
||||
while (directory === normalizedCwd || directory.startsWith(`${normalizedCwd}/`)) {
|
||||
directories.add(directory);
|
||||
if (directory === normalizedCwd) break;
|
||||
const parent = getFileDirectory(directory);
|
||||
if (parent === directory) break;
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
return directories;
|
||||
}, [cwd, gitFiles]);
|
||||
|
||||
const handleToggleExpanded = useCallback((fullPath: string, open: boolean) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) next.add(fullPath); else next.delete(fullPath);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyUploadResult = useCallback((data: UploadResponse) => {
|
||||
const uploaded = data.uploaded ?? [];
|
||||
const skipped = data.skipped ?? [];
|
||||
const errors = data.errors ?? [];
|
||||
setUploadSummary({ uploaded, skipped, errors });
|
||||
|
||||
if (uploaded.length > 0) {
|
||||
setHighlightedPaths(new Set(uploaded.map((name) => joinFilePath(cwd, name))));
|
||||
setTreeRefreshKey((key) => key + 1);
|
||||
}
|
||||
}, [cwd]);
|
||||
|
||||
const performUpload = useCallback(async (
|
||||
files: File[],
|
||||
strategy: UploadConflictStrategy,
|
||||
) => {
|
||||
setPendingConflict(null);
|
||||
setUploadError(null);
|
||||
setUploadProgress(0);
|
||||
setUploadPhase("uploading");
|
||||
|
||||
try {
|
||||
const { status, data } = await uploadFiles(cwd, files, strategy, setUploadProgress);
|
||||
if (status === 409 && data.conflicts?.length) {
|
||||
setPendingConflict({
|
||||
files,
|
||||
conflicts: data.conflicts,
|
||||
nonReplaceable: data.nonReplaceable ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new Error(data.error ?? `Upload failed (HTTP ${status})`);
|
||||
}
|
||||
setUploadProgress(100);
|
||||
applyUploadResult(data);
|
||||
} catch (uploadFailure) {
|
||||
setUploadError(uploadFailure instanceof Error ? uploadFailure.message : String(uploadFailure));
|
||||
} finally {
|
||||
setUploadPhase("idle");
|
||||
}
|
||||
}, [applyUploadResult, cwd]);
|
||||
|
||||
const prepareUpload = useCallback(async (files: File[]) => {
|
||||
if (files.length === 0 || uploadBusy) return;
|
||||
setUploadSummary(null);
|
||||
setHighlightedPaths(new Set());
|
||||
setPendingConflict(null);
|
||||
setUploadError(null);
|
||||
setUploadProgress(0);
|
||||
setUploadPhase("checking");
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/files/${encodeFilePathForApi(cwd)}?type=upload-check`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileNames: files.map((file) => file.name) }),
|
||||
},
|
||||
);
|
||||
const data = await res.json().catch(() => ({})) as UploadResponse;
|
||||
if (!res.ok) throw new Error(data.error ?? `Upload check failed (HTTP ${res.status})`);
|
||||
|
||||
if (data.conflicts?.length) {
|
||||
setPendingConflict({
|
||||
files,
|
||||
conflicts: data.conflicts,
|
||||
nonReplaceable: data.nonReplaceable ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await performUpload(files, "error");
|
||||
} catch (uploadFailure) {
|
||||
setUploadError(uploadFailure instanceof Error ? uploadFailure.message : String(uploadFailure));
|
||||
} finally {
|
||||
setUploadPhase("idle");
|
||||
}
|
||||
}, [cwd, performUpload, uploadBusy]);
|
||||
|
||||
const handleUploadInput = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = "";
|
||||
void prepareUpload(files);
|
||||
}, [prepareUpload]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openUploadPicker() {
|
||||
if (!uploadBusy) uploadInputRef.current?.click();
|
||||
},
|
||||
}), [uploadBusy]);
|
||||
|
||||
useEffect(() => {
|
||||
onUploadBusyChange?.(uploadBusy);
|
||||
}, [onUploadBusyChange, uploadBusy]);
|
||||
|
||||
useEffect(() => () => onUploadBusyChange?.(false), [onUploadBusyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const cwdChanged = prevCwdRef.current !== cwd;
|
||||
prevCwdRef.current = cwd;
|
||||
|
||||
// Reset expanded state only when cwd changes, not on refreshKey bumps
|
||||
if (cwdChanged) {
|
||||
setExpandedPaths(new Set());
|
||||
setHighlightedPaths(new Set());
|
||||
setUploadSummary(null);
|
||||
setPendingConflict(null);
|
||||
setUploadError(null);
|
||||
}
|
||||
|
||||
setLoading(cwdChanged);
|
||||
setError(null);
|
||||
let cancelled = false;
|
||||
fetchEntries(cwd)
|
||||
.then((entries) => { if (!cancelled) setRoots(entries); })
|
||||
.catch((e) => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [cwd, refreshKey, treeRefreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchGitStatus(cwd)
|
||||
.then((status) => {
|
||||
if (!cancelled) {
|
||||
setGitFiles(status.isGitRepository ? status.files : []);
|
||||
setGitLineStats(status.isGitRepository
|
||||
? { additions: status.additions, deletions: status.deletions }
|
||||
: { additions: 0, deletions: 0 });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setGitFiles([]);
|
||||
setGitLineStats({ additions: 0, deletions: 0 });
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [cwd, refreshKey, treeRefreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangesCountChange?.(gitFiles.length);
|
||||
}, [gitFiles, onChangesCountChange]);
|
||||
|
||||
const showUploadFeedback = uploadBusy || pendingConflict !== null || uploadError !== null || uploadSummary !== null;
|
||||
|
||||
const addUploadedFilesToChat = useCallback(() => {
|
||||
if (!uploadSummary || uploadSummary.uploaded.length === 0) return;
|
||||
onAtMentions?.(
|
||||
uploadSummary.uploaded.map((name) => getRelativeFilePath(joinFilePath(cwd, name), cwd)),
|
||||
);
|
||||
}, [cwd, onAtMentions, uploadSummary]);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100%" }}>
|
||||
<input ref={uploadInputRef} type="file" multiple hidden onChange={handleUploadInput} />
|
||||
{showUploadFeedback && (
|
||||
<div style={{ padding: "6px 8px", borderBottom: "1px solid var(--border)" }}>
|
||||
{uploadBusy && (
|
||||
<div role="status" aria-live="polite" aria-label={uploadPhase === "checking" ? t("files.checking") : t("files.uploading", { progress: uploadProgress })}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, minHeight: 14, color: "var(--text-muted)" }}>
|
||||
{uploadPhase === "checking" ? (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" style={{ animation: "spin 0.8s linear infinite" }} aria-hidden="true">
|
||||
<path d="M21 12a9 9 0 1 1-5.7-8.4" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 16V4" />
|
||||
<path d="m7 9 5-5 5 5" />
|
||||
<path d="M5 20h14" />
|
||||
</svg>
|
||||
)}
|
||||
{uploadPhase === "uploading" && <span style={{ fontSize: 10 }}>{uploadProgress}%</span>}
|
||||
</div>
|
||||
{uploadPhase === "uploading" && (
|
||||
<div style={{ height: 3, marginTop: 4, overflow: "hidden", borderRadius: 2, background: "var(--border)" }}>
|
||||
<div style={{ width: `${uploadProgress}%`, height: "100%", background: "var(--text-muted)", transition: "width 120ms ease" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingConflict && (
|
||||
<div role="alert" style={{ padding: 7, border: "1px solid color-mix(in srgb, #f59e0b 55%, var(--border))", borderRadius: 4, background: "color-mix(in srgb, #f59e0b 9%, var(--bg-panel))" }}>
|
||||
<div style={{ fontSize: 11, color: "var(--text)", lineHeight: 1.35, overflowWrap: "anywhere" }}>
|
||||
{t("files.conflictSummary", { count: pendingConflict.conflicts.length, countSuffix: pendingConflict.conflicts.length === 1 ? "" : "s", files: pendingConflict.conflicts.join(", ") })}
|
||||
</div>
|
||||
{pendingConflict.nonReplaceable.length > 0 && (
|
||||
<div style={{ marginTop: 3, fontSize: 10, color: "#f59e0b", lineHeight: 1.35, overflowWrap: "anywhere" }}>
|
||||
{t("files.cannotReplace", { files: pendingConflict.nonReplaceable.join(", ") })}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 5, marginTop: 7 }}>
|
||||
<button type="button" onClick={() => void performUpload(pendingConflict.files, "overwrite")} style={{ height: 22, padding: "0 7px", border: "1px solid #ef4444", borderRadius: 4, background: "transparent", color: "#ef4444", cursor: "pointer", fontSize: 10 }}>
|
||||
{t("files.replace")}
|
||||
</button>
|
||||
<button type="button" onClick={() => void performUpload(pendingConflict.files, "skip")} style={{ height: 22, padding: "0 7px", border: "1px solid var(--border)", borderRadius: 4, background: "var(--bg-panel)", color: "var(--text)", cursor: "pointer", fontSize: 10 }}>
|
||||
{t("files.skipExisting")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setPendingConflict(null)} style={{ height: 22, padding: "0 7px", border: "none", borderRadius: 4, background: "transparent", color: "var(--text-muted)", cursor: "pointer", fontSize: 10 }}>
|
||||
{t("files.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<div role="alert" style={{ display: "flex", alignItems: "flex-start", gap: 6, fontSize: 11, lineHeight: 1.35, color: "#f87171" }}>
|
||||
<span style={{ minWidth: 0, flex: 1, overflowWrap: "anywhere" }}>{uploadError}</span>
|
||||
<DismissButton onClick={() => setUploadError(null)} title={t("files.dismissError")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadSummary && (
|
||||
<div aria-live="polite">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minHeight: 22, fontSize: 11 }}>
|
||||
<div style={{ minWidth: 0, flex: 1, display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{uploadSummary.uploaded.length > 0 && (
|
||||
<span title={`${uploadSummary.uploaded.length} uploaded`} aria-label={`${uploadSummary.uploaded.length} uploaded`} style={{ display: "flex", alignItems: "center", gap: 3, color: "#22c55e" }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m5 12 4 4L19 6" />
|
||||
</svg>
|
||||
<span>{uploadSummary.uploaded.length}</span>
|
||||
</span>
|
||||
)}
|
||||
{uploadSummary.skipped.length > 0 && (
|
||||
<span title={`${uploadSummary.skipped.length} skipped`} aria-label={`${uploadSummary.skipped.length} skipped`} style={{ display: "flex", alignItems: "center", gap: 3, color: "var(--text-dim)" }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M8 12h8" />
|
||||
</svg>
|
||||
<span>{uploadSummary.skipped.length}</span>
|
||||
</span>
|
||||
)}
|
||||
{uploadSummary.errors.length > 0 && (
|
||||
<span title={`${uploadSummary.errors.length} failed`} aria-label={`${uploadSummary.errors.length} failed`} style={{ display: "flex", alignItems: "center", gap: 3, color: "#f87171" }}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 3 2.5 20h19L12 3Z" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
<span>{uploadSummary.errors.length}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{uploadSummary.uploaded.length > 0 && onAtMentions && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addUploadedFilesToChat}
|
||||
title={uploadSummary.uploaded.length === 1 ? t("files.addUploadedFile") : t("files.addAllUploadedFiles")}
|
||||
aria-label={uploadSummary.uploaded.length === 1 ? t("files.addUploadedFile") : t("files.addAllUploadedFiles")}
|
||||
style={{ height: 22, padding: "0 7px", display: "flex", alignItems: "center", justifyContent: "center", gap: 4, flexShrink: 0, border: "1px solid var(--border)", borderRadius: 4, background: "var(--bg-panel)", color: "var(--accent)", cursor: "pointer", fontSize: 11, fontWeight: 600, whiteSpace: "nowrap" }}
|
||||
>
|
||||
<MentionIcon />
|
||||
{t("files.mention")}
|
||||
</button>
|
||||
)}
|
||||
<DismissButton onClick={() => setUploadSummary(null)} title={t("files.dismissUploadResults")} />
|
||||
</div>
|
||||
{uploadSummary.errors.map((item) => (
|
||||
<div key={item.name} title={item.error} style={{ display: "flex", alignItems: "center", gap: 4, marginTop: 3, minWidth: 0, fontSize: 10, color: "#f87171" }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 8v5" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
<span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!changesCollapsed && gitFiles.length > 0 && (
|
||||
<div style={{ padding: "0 4px 2px" }}>
|
||||
<div
|
||||
aria-label={t("files.changeStats", {
|
||||
count: gitFiles.length,
|
||||
additions: gitLineStats.additions,
|
||||
deletions: gitLineStats.deletions,
|
||||
})}
|
||||
style={{ display: "flex", alignItems: "center", gap: 6, height: 24, padding: "0 10px", fontSize: 12 }}
|
||||
>
|
||||
<span style={{ color: "var(--text-dim)" }}>
|
||||
{t("files.changedCount", { count: gitFiles.length })}
|
||||
</span>
|
||||
<span style={{ color: GIT_STATUS_COLORS.added, fontFamily: "var(--font-mono)" }}>+{gitLineStats.additions}</span>
|
||||
<span style={{ color: GIT_STATUS_COLORS.deleted, fontFamily: "var(--font-mono)" }}>-{gitLineStats.deletions}</span>
|
||||
</div>
|
||||
{gitFiles.map((status) => (
|
||||
<ChangeRow key={status.filePath} status={status} cwd={cwd} onOpenFile={onOpenFile} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(changesCollapsed || gitFiles.length === 0) && (
|
||||
<div style={{ padding: "2px 4px" }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: "8px 12px", fontSize: 11, color: "var(--text-dim)" }}>Loading files...</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: "8px 12px", fontSize: 11, color: "#f87171" }}>{error}</div>
|
||||
) : (
|
||||
roots.map((node) => (
|
||||
<TreeNode
|
||||
key={node.fullPath}
|
||||
node={node}
|
||||
depth={0}
|
||||
cwd={cwd}
|
||||
onOpenFile={onOpenFile}
|
||||
onAtMention={onAtMention}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggleExpanded={handleToggleExpanded}
|
||||
refreshToken={refreshToken}
|
||||
highlightedPaths={highlightedPaths}
|
||||
gitStatusByPath={gitStatusByPath}
|
||||
changedDirectoryPaths={changedDirectoryPaths}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{!loading && !error && roots.length === 0 && (
|
||||
<div style={{ padding: "8px 12px", fontSize: 11, color: "var(--text-dim)" }}>
|
||||
{t("files.noFiles")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
// Flat monochrome file & folder icons — all use currentColor / var(--text-dim)
|
||||
|
||||
interface IconProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const DIM = "var(--text-dim)";
|
||||
|
||||
// ── Folder ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FolderIcon({ size = 14, open = false }: IconProps & { open?: boolean }) {
|
||||
if (open) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M1 4.5A1 1 0 0 1 2 3.5H5.5L7 5h7.5v1H1V4.5Z" fill={DIM} />
|
||||
<path d="M1 6h14.5L14 13H2L1 6Z" stroke={DIM} strokeWidth="1" fill={DIM} fillOpacity="0.12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M1 4.5A1 1 0 0 1 2 3.5H5.5L7 5H14a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4.5Z"
|
||||
stroke={DIM} strokeWidth="1" fill={DIM} fillOpacity="0.1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generic file (fallback) ────────────────────────────────────────────────
|
||||
|
||||
export function GenericFileIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
||||
<path d="M3 2h7l3 3v9H3V2Z" stroke={DIM} strokeWidth="1" fill={DIM} fillOpacity="0.08" />
|
||||
<path d="M10 2v3h3" stroke={DIM} strokeWidth="1" fill="none" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── File with label text (used for most types) ────────────────────────────
|
||||
// Renders the file outline + a short text badge
|
||||
|
||||
function LabelFileIcon({ label, size = 14 }: { label: string; size?: number }) {
|
||||
const s = size / 14; // scale factor
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<text
|
||||
x="7" y="9.5"
|
||||
textAnchor="middle"
|
||||
fontSize={3.4 * s}
|
||||
fontFamily="var(--font-mono), monospace"
|
||||
fontWeight="600"
|
||||
fill={DIM}
|
||||
letterSpacing="0"
|
||||
>{label}</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Specific icons ────────────────────────────────────────────────────────
|
||||
|
||||
export function TypeScriptIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="TS" size={size} />;
|
||||
}
|
||||
export function TypeScriptReactIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="TSX" size={size} />;
|
||||
}
|
||||
export function JavaScriptIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="JS" size={size} />;
|
||||
}
|
||||
export function JavaScriptReactIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="JSX" size={size} />;
|
||||
}
|
||||
export function PythonIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="PY" size={size} />;
|
||||
}
|
||||
export function JsonIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="{}" size={size} />;
|
||||
}
|
||||
export function CssIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="CSS" size={size} />;
|
||||
}
|
||||
export function ScssIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="SC" size={size} />;
|
||||
}
|
||||
export function HtmlIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="HTM" size={size} />;
|
||||
}
|
||||
export function MarkdownIcon({ size = 14 }: IconProps) {
|
||||
// file outline + M↓ symbol
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
{/* M */}
|
||||
<path d="M3.5 9.5V7l1.5 1.5L6.5 7v2.5" stroke={DIM} strokeWidth="0.9" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
{/* down arrow */}
|
||||
<path d="M8 7v2.5M7 9l1 1.5 1-1.5" stroke={DIM} strokeWidth="0.9" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function YamlIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="YML" size={size} />;
|
||||
}
|
||||
export function TomlIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="TOM" size={size} />;
|
||||
}
|
||||
export function ShellIcon({ size = 14 }: IconProps) {
|
||||
// file outline + > prompt
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<path d="M4 7.5l2 1.5-2 1.5" stroke={DIM} strokeWidth="0.95" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
<path d="M7.5 10.5h2.5" stroke={DIM} strokeWidth="0.95" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function RustIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="RS" size={size} />;
|
||||
}
|
||||
export function GoIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="GO" size={size} />;
|
||||
}
|
||||
export function SqlIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="SQL" size={size} />;
|
||||
}
|
||||
export function GraphqlIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="GQL" size={size} />;
|
||||
}
|
||||
export function TerraformIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="TF" size={size} />;
|
||||
}
|
||||
export function DockerfileIcon({ size = 14 }: IconProps) {
|
||||
// file outline + container stack
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<rect x="3.5" y="6.5" width="2" height="1.5" rx="0.3" stroke={DIM} strokeWidth="0.8" />
|
||||
<rect x="6" y="6.5" width="2" height="1.5" rx="0.3" stroke={DIM} strokeWidth="0.8" />
|
||||
<rect x="3.5" y="8.5" width="2" height="1.5" rx="0.3" stroke={DIM} strokeWidth="0.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function EnvIcon({ size = 14 }: IconProps) {
|
||||
// file outline + key symbol
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<circle cx="5.5" cy="8.5" r="1.5" stroke={DIM} strokeWidth="0.9" />
|
||||
<path d="M7 8.5h2.5M8.5 8.5v1.5" stroke={DIM} strokeWidth="0.9" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function GitIcon({ size = 14 }: IconProps) {
|
||||
// file outline + git branch lines
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<circle cx="5" cy="6.5" r="1" stroke={DIM} strokeWidth="0.85" />
|
||||
<circle cx="9" cy="6.5" r="1" stroke={DIM} strokeWidth="0.85" />
|
||||
<circle cx="5" cy="10" r="1" stroke={DIM} strokeWidth="0.85" />
|
||||
<path d="M5 7.5V9" stroke={DIM} strokeWidth="0.85" strokeLinecap="round" />
|
||||
<path d="M9 7.5v.5a2 2 0 0 1-2 2H6" stroke={DIM} strokeWidth="0.85" strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function LockFileIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<rect x="4.5" y="8.5" width="5" height="3" rx="0.6" stroke={DIM} strokeWidth="0.9" />
|
||||
<path d="M5.5 8.5V7.5a1.5 1.5 0 0 1 3 0v1" stroke={DIM} strokeWidth="0.9" strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
export function DocFileIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="DOC" size={size} />;
|
||||
}
|
||||
export function PdfFileIcon({ size = 14 }: IconProps) {
|
||||
return <LabelFileIcon label="PDF" size={size} />;
|
||||
}
|
||||
export function ConfigIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 14 14" fill="none">
|
||||
<path d="M2.5 1h6l3 3v9h-9V1Z" stroke={DIM} strokeWidth="0.9" fill={DIM} fillOpacity="0.07" strokeLinejoin="round" />
|
||||
<path d="M8.5 1v3h3" stroke={DIM} strokeWidth="0.9" fill="none" strokeLinejoin="round" />
|
||||
<circle cx="7" cy="8.5" r="1.3" stroke={DIM} strokeWidth="0.9" />
|
||||
<path d="M7 6.5v.7M7 10.3v.7M5 8.5h.7M8.3 8.5H9M5.5 6.9l.5.5M8.5 9.6l-.5-.5M5.5 10.1l.5-.5M8.5 7.4l-.5.5"
|
||||
stroke={DIM} strokeWidth="0.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main resolver ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getFileIcon(name: string, size = 14): React.ReactNode {
|
||||
const lower = name.toLowerCase();
|
||||
const ext = lower.split(".").pop() ?? "";
|
||||
|
||||
if (lower === "dockerfile" || lower.startsWith("dockerfile.")) return <DockerfileIcon size={size} />;
|
||||
if (lower === ".env" || lower.startsWith(".env.")) return <EnvIcon size={size} />;
|
||||
if (lower === ".gitignore" || lower === ".gitattributes" || lower === ".gitmodules") return <GitIcon size={size} />;
|
||||
if (lower === "package-lock.json" || lower === "yarn.lock" || lower === "bun.lock" || lower === "pnpm-lock.yaml" || lower === "cargo.lock") return <LockFileIcon size={size} />;
|
||||
if (lower.endsWith(".config.ts") || lower.endsWith(".config.js") || lower.endsWith(".config.mjs") || lower.endsWith(".config.cjs")) return <ConfigIcon size={size} />;
|
||||
if ([".eslintrc", ".eslintrc.js", ".eslintrc.json", ".eslintrc.yml", "eslint.config.mjs", "eslint.config.js"].includes(lower)) return <ConfigIcon size={size} />;
|
||||
|
||||
switch (ext) {
|
||||
case "ts": return <TypeScriptIcon size={size} />;
|
||||
case "tsx": return <TypeScriptReactIcon size={size} />;
|
||||
case "js":
|
||||
case "mjs":
|
||||
case "cjs": return <JavaScriptIcon size={size} />;
|
||||
case "jsx": return <JavaScriptReactIcon size={size} />;
|
||||
case "py": return <PythonIcon size={size} />;
|
||||
case "json":
|
||||
case "jsonl": return <JsonIcon size={size} />;
|
||||
case "css":
|
||||
case "less": return <CssIcon size={size} />;
|
||||
case "scss": return <ScssIcon size={size} />;
|
||||
case "html":
|
||||
case "htm": return <HtmlIcon size={size} />;
|
||||
case "md":
|
||||
case "mdx": return <MarkdownIcon size={size} />;
|
||||
case "yaml":
|
||||
case "yml": return <YamlIcon size={size} />;
|
||||
case "toml": return <TomlIcon size={size} />;
|
||||
case "sh":
|
||||
case "bash":
|
||||
case "zsh":
|
||||
case "fish": return <ShellIcon size={size} />;
|
||||
case "rs": return <RustIcon size={size} />;
|
||||
case "go": return <GoIcon size={size} />;
|
||||
case "sql": return <SqlIcon size={size} />;
|
||||
case "graphql":
|
||||
case "gql": return <GraphqlIcon size={size} />;
|
||||
case "tf":
|
||||
case "hcl": return <TerraformIcon size={size} />;
|
||||
case "docx": return <DocFileIcon size={size} />;
|
||||
case "pdf": return <PdfFileIcon size={size} />;
|
||||
case "lock": return <LockFileIcon size={size} />;
|
||||
default: return <GenericFileIcon size={size} />;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
jsx: { runtime: "automatic" },
|
||||
tsconfigPaths: true,
|
||||
});
|
||||
const { MarkdownBody } = await jiti.import("./MarkdownBody.tsx");
|
||||
const { normalizeDisplayMath } = await jiti.import("../lib/markdown.ts");
|
||||
|
||||
function renderMarkdown(markdown) {
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(MarkdownBody, {
|
||||
cwd: "/home/me/project",
|
||||
onOpenFile() {},
|
||||
}, markdown),
|
||||
);
|
||||
}
|
||||
|
||||
test("opens non-file markdown links in a safe new tab", () => {
|
||||
const html = renderMarkdown("[docs](https://example.com/docs)");
|
||||
|
||||
assert.match(
|
||||
html,
|
||||
/<a (?=[^>]*href="https:\/\/example\.com\/docs")(?=[^>]*target="_blank")(?=[^>]*rel="noopener noreferrer")[^>]*>docs<\/a>/,
|
||||
);
|
||||
assert.doesNotMatch(html, /\snode=/);
|
||||
});
|
||||
|
||||
test("keeps local file markdown links in the app", () => {
|
||||
const html = renderMarkdown("[file](components/MarkdownBody.tsx)");
|
||||
|
||||
assert.match(html, /<a href="components\/MarkdownBody\.tsx">file<\/a>/);
|
||||
assert.doesNotMatch(html, /target=|rel=|\snode=/);
|
||||
});
|
||||
|
||||
test("renders LaTeX parenthesis delimiters as inline math", () => {
|
||||
const html = renderMarkdown(String.raw`射线为 \(r_c = K^{-1}p\)。`);
|
||||
|
||||
assert.match(html, /class="katex"/);
|
||||
assert.match(html, /r_c/);
|
||||
});
|
||||
|
||||
test("renders paired LaTeX bracket delimiters as display math", () => {
|
||||
const html = renderMarkdown(String.raw`\[
|
||||
P(\lambda)=o_b+\lambda r_b
|
||||
\]`);
|
||||
const oneLineHtml = renderMarkdown(String.raw`\[P(\lambda)=o_b+\lambda r_b\]`);
|
||||
|
||||
assert.match(html, /class="katex-display"/);
|
||||
assert.match(html, /lambda/);
|
||||
assert.match(oneLineHtml, /class="katex-display"/);
|
||||
});
|
||||
|
||||
test("leaves an unmatched LaTeX bracket delimiter unchanged", () => {
|
||||
const markdown = String.raw`before
|
||||
\[
|
||||
x + y
|
||||
after`;
|
||||
|
||||
assert.equal(normalizeDisplayMath(markdown), markdown);
|
||||
});
|
||||
|
||||
test("does not normalize LaTeX delimiters inside Markdown code", () => {
|
||||
const markdown = " \\(indented\\)\n\n`code\n\\(inline\\)`\n\n```text\n\\[\nfenced\n\\]\n```";
|
||||
|
||||
assert.equal(normalizeDisplayMath(markdown), markdown);
|
||||
});
|
||||
|
||||
test("does not normalize LaTeX delimiters inside raw HTML code", () => {
|
||||
const markdown = "<code>\\(inline\\)</code>\n\n<pre>\n\\(block\\)\n</pre>";
|
||||
|
||||
assert.equal(normalizeDisplayMath(markdown), markdown);
|
||||
});
|
||||
|
||||
test("does not normalize escaped delimiters or link destinations", () => {
|
||||
const escaped = String.raw`Literal: \\(x+y\\).`;
|
||||
const link = String.raw`[docs](https://example.com/\(manual\))`;
|
||||
|
||||
assert.equal(normalizeDisplayMath(escaped), escaped);
|
||||
assert.equal(normalizeDisplayMath(link), link);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type MouseEvent } from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import { resolveLocalFileHref } from "@/lib/file-links";
|
||||
import { encodeFilePathForApi } from "@/lib/file-paths";
|
||||
import { markdownRehypePlugins, markdownRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown";
|
||||
import { MermaidBlock, CodeBlock } from "./MermaidBlock";
|
||||
|
||||
interface MarkdownBodyProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
isStreaming?: boolean;
|
||||
cwd?: string;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile }: MarkdownBodyProps) {
|
||||
const normalizedMarkdown = useMemo(() => normalizeDisplayMath(children), [children]);
|
||||
// Stable renderer identities keep stateful blocks mounted across message hover updates.
|
||||
const components = useMemo<Components>(() => ({
|
||||
code({ className, children, ...props }) {
|
||||
const lang = className?.replace("language-", "").toLowerCase() ?? "";
|
||||
const raw = String(children);
|
||||
const isBlock = className?.includes("language-") || raw.includes("\n");
|
||||
if (isBlock) {
|
||||
if (lang === "mermaid") {
|
||||
return <MermaidBlock code={raw.replace(/\n$/, "")} isStreaming={isStreaming} />;
|
||||
}
|
||||
return <CodeBlock code={raw.replace(/\n$/, "")} lang={lang} />;
|
||||
}
|
||||
return (
|
||||
<code
|
||||
className="markdown-inline-code"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children }) {
|
||||
return <>{children}</>;
|
||||
},
|
||||
a({ href, children, ...props }) {
|
||||
// `node` is react-markdown metadata, not a DOM attribute.
|
||||
delete props.node;
|
||||
const filePath = onOpenFile ? resolveLocalFileHref(href, cwd) : null;
|
||||
const openFile = onOpenFile;
|
||||
if (!filePath || !openFile) {
|
||||
return (
|
||||
<a href={href} {...props} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.defaultPrevented || event.button !== 0) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
const target = event.currentTarget.getAttribute("target");
|
||||
if (target && target !== "_self") return;
|
||||
event.preventDefault();
|
||||
openFile(filePath);
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={href} {...props} onClick={handleClick}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img({ src, alt, ...props }) {
|
||||
delete props.node;
|
||||
const filePath = typeof src === "string" ? resolveLocalFileHref(src, cwd) : null;
|
||||
const imageSrc = filePath
|
||||
? `/api/files/${encodeFilePathForApi(filePath)}?type=read`
|
||||
: src;
|
||||
// Dynamic local paths are served directly by the file API.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={imageSrc} alt={alt ?? ""} loading="lazy" {...props} />;
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="markdown-table-wrap">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}), [cwd, isStreaming, onOpenFile]);
|
||||
|
||||
return (
|
||||
<div className={["markdown-body", className].filter(Boolean).join(" ")}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{normalizedMarkdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
jsx: { runtime: "automatic" },
|
||||
tsconfigPaths: true,
|
||||
});
|
||||
const { MermaidBlock } = await jiti.import("./MermaidBlock.tsx");
|
||||
const { I18nProvider } = await jiti.import("../hooks/useI18n.tsx");
|
||||
|
||||
// Simple sequenceDiagram for testing
|
||||
const mermaidSrc = `sequenceDiagram
|
||||
Alice->>Bob: Hello
|
||||
Bob-->>Alice: Hi`;
|
||||
|
||||
function renderMermaid(props) {
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
null,
|
||||
React.createElement(MermaidBlock, props),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test("MermaidBlock renders source by default", () => {
|
||||
const html = renderMermaid({ code: mermaidSrc });
|
||||
|
||||
assert.match(html, />Preview</);
|
||||
assert.match(html, /Alice/);
|
||||
assert.doesNotMatch(html, /mermaid-block-loading/);
|
||||
});
|
||||
|
||||
test("MermaidBlock can render preview by default", () => {
|
||||
const html = renderMermaid({ code: mermaidSrc, defaultPreview: true });
|
||||
|
||||
assert.match(html, />Source</);
|
||||
assert.match(html, /mermaid-block-loading/);
|
||||
assert.doesNotMatch(html, /Alice/);
|
||||
});
|
||||
|
||||
test("MermaidBlock with isStreaming falls back to source view", () => {
|
||||
const html = renderMermaid({ code: mermaidSrc, isStreaming: true, defaultPreview: true });
|
||||
|
||||
assert.match(html, /disabled/);
|
||||
assert.match(html, />Preview</);
|
||||
assert.match(html, /Alice/);
|
||||
assert.match(html, /->>/);
|
||||
});
|
||||
|
||||
test("MermaidBlock renders empty graph without error", () => {
|
||||
const html = renderMermaid({ code: "graph TD", defaultPreview: true });
|
||||
|
||||
assert.doesNotMatch(html, /mermaid-block-error/);
|
||||
assert.match(html, /mermaid-block-loading/);
|
||||
});
|
||||
|
||||
test("MermaidBlock handles Chinese characters in diagram", () => {
|
||||
const chineseMermaid = `sequenceDiagram
|
||||
participant PC as PC客户端
|
||||
PC->>SV: 请求登录`;
|
||||
|
||||
const html = renderMermaid({ code: chineseMermaid, defaultPreview: true });
|
||||
|
||||
assert.doesNotMatch(html, /mermaid-block-error/);
|
||||
assert.match(html, /mermaid-block/);
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
|
||||
interface MermaidBlockProps {
|
||||
code: string;
|
||||
isStreaming?: boolean;
|
||||
defaultPreview?: boolean;
|
||||
}
|
||||
|
||||
const ZOOM_STEP = 0.25;
|
||||
const ZOOM_MIN = 0.5;
|
||||
const ZOOM_MAX = 3;
|
||||
|
||||
type RenderState =
|
||||
| { key: string; status: "loading" }
|
||||
| { key: string; status: "error" }
|
||||
| { key: string; status: "ready"; svg: string };
|
||||
|
||||
export function MermaidBlock({ code, isStreaming, defaultPreview = false }: MermaidBlockProps) {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [showPreview, setShowPreview] = useState(defaultPreview);
|
||||
const [renderState, setRenderState] = useState<RenderState | null>(null);
|
||||
const [zoomOpen, setZoomOpen] = useState(false);
|
||||
const currentKey = `${isDark ? "dark" : "light"}\n${code}`;
|
||||
const previewVisible = showPreview && !isStreaming;
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewVisible) return;
|
||||
|
||||
let cancelled = false;
|
||||
setRenderState({ key: currentKey, status: "loading" });
|
||||
|
||||
const render = async () => {
|
||||
const { default: mermaid } = await import("mermaid");
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
suppressErrorRendering: true,
|
||||
theme: isDark ? "dark" : "default",
|
||||
});
|
||||
|
||||
const parsed = await mermaid.parse(code, { suppressErrors: true });
|
||||
if (!parsed) throw new Error("Invalid Mermaid diagram");
|
||||
|
||||
const id =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? `mermaid-${crypto.randomUUID()}`
|
||||
: `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const result = await mermaid.render(id, code);
|
||||
if (!cancelled) {
|
||||
setRenderState({ key: currentKey, status: "ready", svg: result.svg });
|
||||
}
|
||||
};
|
||||
|
||||
render().catch(() => {
|
||||
if (!cancelled) setRenderState({ key: currentKey, status: "error" });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, currentKey, isDark, previewVisible]);
|
||||
|
||||
const previewButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPreview((v) => !v)}
|
||||
disabled={isStreaming}
|
||||
title={isStreaming ? t("i18n.previewAfterStreaming") : (previewVisible ? t("i18n.showMermaidSource") : t("i18n.previewMermaid"))}
|
||||
className={["markdown-code-action", previewVisible ? "is-active" : ""].filter(Boolean).join(" ")}
|
||||
>
|
||||
{previewVisible ? t("i18n.source") : t("i18n.preview")}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!previewVisible) {
|
||||
return <CodeBlock code={code} lang="mermaid" headerAction={previewButton} />;
|
||||
}
|
||||
|
||||
const body = renderState?.key === currentKey && renderState.status === "error" ? (
|
||||
<div className="mermaid-block mermaid-block-error">{t("i18n.invalidMermaid")}</div>
|
||||
) : renderState?.key !== currentKey || renderState.status !== "ready" ? (
|
||||
<div className="mermaid-block mermaid-block-loading" aria-label={t("i18n.renderingMermaid")} />
|
||||
) : (
|
||||
<>
|
||||
{!zoomOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="mermaid-block mermaid-preview-button"
|
||||
title={t("i18n.openMermaidViewer")}
|
||||
aria-label={t("i18n.openMermaidViewer")}
|
||||
onClick={() => setZoomOpen(true)}
|
||||
dangerouslySetInnerHTML={{ __html: renderState.svg }}
|
||||
/>
|
||||
)}
|
||||
{zoomOpen && <MermaidZoomDialog svg={renderState.svg} onClose={() => setZoomOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="markdown-code-block">
|
||||
<div className="markdown-code-header">
|
||||
<span className="markdown-code-lang">mermaid</span>
|
||||
{previewButton}
|
||||
</div>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MermaidZoomDialog({ svg, onClose }: { svg: string; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
dialog.showModal();
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
if (dialog.open) dialog.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="mermaid-zoom-dialog"
|
||||
aria-label={t("i18n.mermaidViewer")}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="mermaid-zoom-layout">
|
||||
<div className="mermaid-zoom-toolbar">
|
||||
<span className="mermaid-zoom-title">{t("i18n.mermaidDiagram")}</span>
|
||||
<div className="mermaid-zoom-actions">
|
||||
<div className="mermaid-zoom-stepper">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((value) => Math.max(ZOOM_MIN, value - ZOOM_STEP))}
|
||||
disabled={zoom <= ZOOM_MIN}
|
||||
title={t("i18n.zoomOut")}
|
||||
aria-label={t("i18n.zoomOut")}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="mermaid-zoom-value">{Math.round(zoom * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom((value) => Math.min(ZOOM_MAX, value + ZOOM_STEP))}
|
||||
disabled={zoom >= ZOOM_MAX}
|
||||
title={t("i18n.zoomIn")}
|
||||
aria-label={t("i18n.zoomIn")}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mermaid-zoom-icon-button"
|
||||
onClick={() => setZoom(1)}
|
||||
title={t("i18n.fitToWidth")}
|
||||
aria-label={t("i18n.fitToWidth")}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mermaid-zoom-icon-button"
|
||||
onClick={onClose}
|
||||
title={t("i18n.close")}
|
||||
aria-label={t("i18n.close")}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mermaid-zoom-viewport"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mermaid-zoom-canvas"
|
||||
style={{ width: `${zoom * 100}%` }}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
lang: string;
|
||||
headerAction?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntax-highlighted code block with copy button.
|
||||
* Used as the "source" view for mermaid blocks and for all non-mermaid code fences.
|
||||
*/
|
||||
export function CodeBlock({ code, lang, headerAction }: CodeBlockProps) {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = () => {
|
||||
copyText(code).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="markdown-code-block">
|
||||
<div className="markdown-code-header">
|
||||
<span className="markdown-code-lang">{lang || "text"}</span>
|
||||
<div className="markdown-code-actions">
|
||||
{headerAction}
|
||||
<button
|
||||
onClick={copy}
|
||||
className="markdown-code-action"
|
||||
>
|
||||
{copied ? t("i18n.copied") : t("i18n.copy")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={lang || "text"}
|
||||
style={isDark ? vscDarkPlus : vs}
|
||||
showLineNumbers
|
||||
lineNumberStyle={{ color: "var(--text-dim)", fontStyle: "normal" }}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "11px 13px",
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.62,
|
||||
borderRadius: 0,
|
||||
background: "color-mix(in srgb, var(--bg) 92%, var(--bg-panel))",
|
||||
}}
|
||||
codeTagProps={{ style: { fontFamily: "var(--font-mono)" } }}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
jsx: { runtime: "automatic" },
|
||||
tsconfigPaths: true,
|
||||
});
|
||||
const { MessageView } = await jiti.import("./MessageView.tsx");
|
||||
const { I18nProvider } = await jiti.import("../hooks/useI18n.tsx");
|
||||
|
||||
function renderMessage(message) {
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
null,
|
||||
React.createElement(MessageView, { message }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test("renders a provider error when the assistant message has no content", () => {
|
||||
const html = renderMessage({
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-test",
|
||||
content: [],
|
||||
stopReason: "error",
|
||||
errorMessage: "OpenAI API error (403): <html>request forbidden</html>",
|
||||
});
|
||||
|
||||
assert.match(html, /role="alert"/);
|
||||
assert.match(html, /Error: OpenAI API error \(403\)/);
|
||||
assert.match(html, /<html>request forbidden<\/html>/);
|
||||
});
|
||||
|
||||
test("renders partial assistant content before the provider error", () => {
|
||||
const html = renderMessage({
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-test",
|
||||
content: [{ type: "text", text: "Partial response" }],
|
||||
stopReason: "error",
|
||||
errorMessage: "Connection closed",
|
||||
});
|
||||
|
||||
assert.match(html, /Partial response/);
|
||||
assert.match(html, /Error: Connection closed/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,993 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { sendAgentCommand } from "@/lib/agent-client";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import type { PluginPackageInfo, PluginsResponse } from "@/lib/api-types";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
type PluginScope = PluginPackageInfo["scope"];
|
||||
type PluginAction = "install" | "remove" | "update" | "disable" | "enable";
|
||||
|
||||
function shortenPath(path: string): string {
|
||||
return path.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
||||
}
|
||||
|
||||
function packageKey(pkg: Pick<PluginPackageInfo, "source" | "scope">): string {
|
||||
return `${pkg.scope}\0${pkg.source}`;
|
||||
}
|
||||
|
||||
function resourceSummary(pkg: PluginPackageInfo, t: ReturnType<typeof useI18n>["t"]): string {
|
||||
if (pkg.disabled) return t("i18n.disabled");
|
||||
const parts = [
|
||||
pkg.counts.extensions ? t("i18n.resourceCount", { count: pkg.counts.extensions, label: t("i18n.extensionShort") }) : "",
|
||||
pkg.counts.skills ? t("i18n.resourceCount", { count: pkg.counts.skills, label: t("i18n.skillShort") }) : "",
|
||||
pkg.counts.prompts ? t("i18n.resourceCount", { count: pkg.counts.prompts, label: t("i18n.promptShort") }) : "",
|
||||
pkg.counts.themes ? t("i18n.resourceCount", { count: pkg.counts.themes, label: t("i18n.themeShort") }) : "",
|
||||
].filter(Boolean);
|
||||
return parts.length ? parts.join(" · ") : t("i18n.noResources");
|
||||
}
|
||||
|
||||
function versionSummary(pkg: PluginPackageInfo, t: ReturnType<typeof useI18n>["t"]): string {
|
||||
const parts = [];
|
||||
if (pkg.version) parts.push(t("i18n.installedVersion", { version: pkg.version }));
|
||||
if (pkg.configuredVersion) parts.push(t("i18n.configuredVersion", { version: pkg.configuredVersion }));
|
||||
return parts.length ? parts.join(" · ") : t("i18n.unknown");
|
||||
}
|
||||
|
||||
function installLocation(scope: PluginScope, cwd: string): string {
|
||||
return scope === "project"
|
||||
? `${shortenPath(cwd)}/.pi/agent/{npm,git}`
|
||||
: "data/agent/{npm,git}";
|
||||
}
|
||||
|
||||
function findInstalledPackage(
|
||||
packages: PluginPackageInfo[],
|
||||
source: string,
|
||||
scope: PluginScope,
|
||||
): PluginPackageInfo | undefined {
|
||||
const trimmed = source.trim();
|
||||
const withoutNpmPrefix = trimmed.startsWith("npm:") ? trimmed.slice(4) : trimmed;
|
||||
return packages.find((pkg) => pkg.scope === scope && pkg.source === trimmed)
|
||||
?? packages.find((pkg) => pkg.scope === scope && pkg.source === `npm:${withoutNpmPrefix}`)
|
||||
?? packages.find((pkg) => pkg.scope === scope && pkg.source.endsWith(trimmed));
|
||||
}
|
||||
|
||||
function statusColor(status: PluginPackageInfo["status"]): string {
|
||||
if (status === "loaded") return "var(--accent)";
|
||||
if (status === "installed") return "#f59e0b";
|
||||
if (status === "disabled") return "var(--text-dim)";
|
||||
return "#ef4444";
|
||||
}
|
||||
|
||||
function ResourceList({ pkg }: { pkg: PluginPackageInfo }) {
|
||||
const { t } = useI18n();
|
||||
const groups = ([
|
||||
["extension", t("i18n.extensions")],
|
||||
["skill", t("i18n.skills")],
|
||||
["prompt", t("i18n.prompts")],
|
||||
["theme", t("i18n.themes")],
|
||||
] as const)
|
||||
.map(([kind, label]) => ({
|
||||
kind,
|
||||
label,
|
||||
resources: pkg.resources.filter((resource) => resource.kind === kind),
|
||||
}))
|
||||
.filter((group) => group.resources.length > 0);
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div style={{ fontSize: 12, color: "var(--text-dim)" }}>
|
||||
{pkg.disabled ? t("i18n.packageDisabled") : t("i18n.noResolvedResources")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div
|
||||
key={group.kind}
|
||||
style={{
|
||||
borderTop: groupIndex === 0 ? "none" : "1px solid var(--border)",
|
||||
paddingTop: groupIndex === 0 ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-dim)",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{group.resources.map((resource) => (
|
||||
<div key={`${resource.kind}:${resource.path}`} style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={resource.path}
|
||||
>
|
||||
{resource.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-dim)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
marginTop: 1,
|
||||
}}
|
||||
title={resource.path}
|
||||
>
|
||||
{resource.relativePath}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeTag({ scope }: { scope: PluginScope }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "1px 5px",
|
||||
borderRadius: 3,
|
||||
flexShrink: 0,
|
||||
background: scope === "project" ? "rgba(99,102,241,0.12)" : "rgba(120,120,120,0.12)",
|
||||
color: scope === "project" ? "rgba(99,102,241,0.85)" : "var(--text-dim)",
|
||||
}}
|
||||
>
|
||||
{scope === "global" ? "app" : scope}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function buttonStyle(disabled?: boolean, danger?: boolean): React.CSSProperties {
|
||||
return {
|
||||
padding: "6px 12px",
|
||||
background: danger ? "rgba(239,68,68,0.08)" : "none",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
color: danger ? "#ef4444" : "var(--text-muted)",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
fontSize: 12,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
};
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
enabled,
|
||||
loading,
|
||||
onToggle,
|
||||
label,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
onToggle: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={loading}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={enabled}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 40,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: loading ? "wait" : "pointer",
|
||||
background: enabled ? "var(--accent)" : "var(--border)",
|
||||
position: "relative",
|
||||
transition: "background 0.18s",
|
||||
outline: "none",
|
||||
opacity: loading ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 3,
|
||||
left: enabled ? 21 : 3,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: "50%",
|
||||
background: "var(--bg)",
|
||||
boxShadow: "0 1px 4px rgba(0,0,0,0.22)",
|
||||
transition: "left 0.18s cubic-bezier(.4,0,.2,1)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AppScope() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 7,
|
||||
overflow: "hidden",
|
||||
height: 30,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 76, textAlign: "center", paddingTop: 6, color: "var(--text)", fontSize: 12 }}>
|
||||
app
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPluginPanel({
|
||||
cwd,
|
||||
source,
|
||||
scope,
|
||||
busy,
|
||||
actionError,
|
||||
onSourceChange,
|
||||
onInstall,
|
||||
}: {
|
||||
cwd: string;
|
||||
source: string;
|
||||
scope: PluginScope;
|
||||
busy: boolean;
|
||||
actionError: string | null;
|
||||
onSourceChange: (value: string) => void;
|
||||
onInstall: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const examples = ["npm:@scope/pi-plugin", "git:https://github.com/user/repo", "/absolute/path/to/plugin"];
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18, maxWidth: 660, minHeight: "100%" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: "var(--text)" }}>
|
||||
{t("i18n.addPlugin")}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-dim)", fontFamily: "var(--font-mono)" }}>
|
||||
{installLocation(scope, cwd)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
|
||||
<label htmlFor="plugin-source" style={{ fontSize: 12, fontWeight: 600, color: "var(--text-muted)" }}>
|
||||
Source
|
||||
</label>
|
||||
<input
|
||||
id="plugin-source"
|
||||
ref={inputRef}
|
||||
value={source}
|
||||
onChange={(e) => onSourceChange(e.target.value)}
|
||||
placeholder="npm:@scope/package"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 36,
|
||||
padding: "0 11px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-panel)",
|
||||
color: "var(--text)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 13,
|
||||
outline: "none",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && source.trim() && !busy) onInstall();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
|
||||
<AppScope />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInstall}
|
||||
disabled={busy || !source.trim()}
|
||||
style={{
|
||||
...buttonStyle(busy || !source.trim()),
|
||||
background: "var(--accent)",
|
||||
color: "white",
|
||||
borderColor: "var(--accent)",
|
||||
}}
|
||||
>
|
||||
{busy ? t("i18n.installing") : t("i18n.install")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-muted)" }}>
|
||||
Examples
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{examples.map((example) => (
|
||||
<button
|
||||
key={example}
|
||||
type="button"
|
||||
onClick={() => onSourceChange(example)}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: 30,
|
||||
textAlign: "left",
|
||||
padding: "6px 9px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-panel)",
|
||||
color: "var(--text-dim)",
|
||||
cursor: "pointer",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--bg-hover)";
|
||||
e.currentTarget.style.color = "var(--text-muted)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "var(--bg-panel)";
|
||||
e.currentTarget.style.color = "var(--text-dim)";
|
||||
}}
|
||||
>
|
||||
{example}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div style={{ fontSize: 12, color: "#ef4444", whiteSpace: "pre-wrap" }}>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageDetail({
|
||||
pkg,
|
||||
cwd,
|
||||
busyKey,
|
||||
actionError,
|
||||
actionMessage,
|
||||
sessionId,
|
||||
onAction,
|
||||
onReloadSession,
|
||||
}: {
|
||||
pkg: PluginPackageInfo;
|
||||
cwd: string;
|
||||
busyKey: string | null;
|
||||
actionError: string | null;
|
||||
actionMessage: string | null;
|
||||
sessionId: string | null;
|
||||
onAction: (action: PluginAction, pkg: PluginPackageInfo) => void;
|
||||
onReloadSession: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const key = packageKey(pkg);
|
||||
const busy = busyKey?.endsWith(key) ?? false;
|
||||
const reloadBusy = busyKey === "reload";
|
||||
const enabled = !pkg.disabled;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20, maxWidth: 680 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 12, minWidth: 0, flexWrap: "wrap" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 180, flex: 1 }}>
|
||||
<Toggle
|
||||
enabled={enabled}
|
||||
loading={busy || reloadBusy}
|
||||
onToggle={() => onAction(pkg.disabled ? "enable" : "disable", pkg)}
|
||||
label={pkg.disabled ? t("i18n.enablePackage") : t("i18n.disablePackage")}
|
||||
/>
|
||||
<ScopeTag scope={pkg.scope} />
|
||||
{pkg.disabled ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "1px 5px",
|
||||
borderRadius: 3,
|
||||
background: "rgba(120,120,120,0.12)",
|
||||
color: "var(--text-dim)",
|
||||
}}
|
||||
>
|
||||
{t("i18n.disabled")}
|
||||
</span>
|
||||
) : pkg.filtered && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: "1px 5px",
|
||||
borderRadius: 3,
|
||||
background: "rgba(245,158,11,0.12)",
|
||||
color: "#d97706",
|
||||
}}
|
||||
>
|
||||
{t("i18n.filtered")}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--text)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{pkg.source}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={() => onAction("update", pkg)}
|
||||
disabled={busy || reloadBusy}
|
||||
style={buttonStyle(busy || reloadBusy)}
|
||||
>
|
||||
{busyKey === `update:${key}` ? t("i18n.updating") : t("i18n.update")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onReloadSession}
|
||||
disabled={!sessionId || reloadBusy || busy}
|
||||
style={buttonStyle(!sessionId || reloadBusy || busy)}
|
||||
title={sessionId ? t("i18n.reloadSession") : t("i18n.openSessionToReload")}
|
||||
>
|
||||
{reloadBusy ? t("i18n.reloading") : t("i18n.reloadSession")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction("remove", pkg)}
|
||||
disabled={busy || reloadBusy}
|
||||
style={buttonStyle(busy || reloadBusy, true)}
|
||||
>
|
||||
{busyKey === `remove:${key}` ? t("i18n.removing") : t("i18n.remove")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(96px, 130px) minmax(0, 1fr)",
|
||||
gap: "9px 14px",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("i18n.status")}</div>
|
||||
<div style={{ color: statusColor(pkg.status), textTransform: "capitalize" }}>{pkg.status}</div>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("i18n.version")}</div>
|
||||
<div style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>{versionSummary(pkg, t)}</div>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("i18n.package")}</div>
|
||||
<div style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)", overflowWrap: "anywhere" }}>
|
||||
{pkg.packageName ?? t("i18n.unknown")}
|
||||
</div>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("i18n.resources")}</div>
|
||||
<div style={{ color: "var(--text-muted)" }}>{resourceSummary(pkg, t)}</div>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("i18n.installedPath")}</div>
|
||||
<div
|
||||
style={{
|
||||
color: pkg.installedPath ? "var(--text-muted)" : "#ef4444",
|
||||
fontFamily: "var(--font-mono)",
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{pkg.installedPath ? shortenPath(pkg.installedPath) : t("i18n.notFound")}
|
||||
</div>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("i18n.cwd")}</div>
|
||||
<div style={{ color: "var(--text-dim)", fontFamily: "var(--font-mono)", overflowWrap: "anywhere" }}>
|
||||
{shortenPath(cwd)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "var(--text)" }}>
|
||||
{t("i18n.resolvedResources")}
|
||||
</div>
|
||||
<ResourceList pkg={pkg} />
|
||||
</div>
|
||||
|
||||
{actionMessage && (
|
||||
<div style={{ fontSize: 12, color: "#16a34a" }}>
|
||||
{actionMessage}
|
||||
</div>
|
||||
)}
|
||||
{actionError && (
|
||||
<div style={{ fontSize: 12, color: "#ef4444", whiteSpace: "pre-wrap" }}>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginsConfig({
|
||||
cwd,
|
||||
sessionId,
|
||||
onClose,
|
||||
onReloaded,
|
||||
}: {
|
||||
cwd: string;
|
||||
sessionId: string | null;
|
||||
onClose: () => void;
|
||||
onReloaded?: () => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<PluginsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [addMode, setAddMode] = useState(false);
|
||||
const [installSource, setInstallSource] = useState("");
|
||||
const installScope: PluginScope = "global";
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
|
||||
const packages = useMemo(() => data?.packages ?? [], [data?.packages]);
|
||||
const selectedPackage = packages.find((pkg) => packageKey(pkg) === selected) ?? null;
|
||||
|
||||
const groupedPackages = useMemo(() => {
|
||||
return (["global"] as PluginScope[])
|
||||
.map((scope) => ({ scope, packages: packages.filter((pkg) => pkg.scope === scope) }))
|
||||
.filter((group) => group.packages.length > 0);
|
||||
}, [packages]);
|
||||
|
||||
const loadPlugins = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/plugins?cwd=${encodeURIComponent(cwd)}`);
|
||||
const next = (await res.json()) as PluginsResponse & { error?: string };
|
||||
if (!res.ok || next.error) throw new Error(next.error ?? `HTTP ${res.status}`);
|
||||
setData(next);
|
||||
setAddMode((current) => next.packages.length === 0 || current);
|
||||
setSelected((current) => {
|
||||
if (current && next.packages.some((pkg) => packageKey(pkg) === current)) return current;
|
||||
return next.packages[0] ? packageKey(next.packages[0]) : null;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cwd]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPlugins();
|
||||
}, [loadPlugins]);
|
||||
|
||||
const runAction = useCallback(async (action: PluginAction, pkg: PluginPackageInfo) => {
|
||||
const key = packageKey(pkg);
|
||||
setBusyKey(`${action}:${key}`);
|
||||
setActionError(null);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/plugins", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, source: pkg.source, scope: pkg.scope, cwd }),
|
||||
});
|
||||
const next = (await res.json()) as PluginsResponse & { error?: string };
|
||||
if (!res.ok || next.error) throw new Error(next.error ?? `HTTP ${res.status}`);
|
||||
setData(next);
|
||||
if (action === "remove") {
|
||||
setSelected(next.packages[0] ? packageKey(next.packages[0]) : null);
|
||||
if (next.packages.length === 0) setAddMode(true);
|
||||
setActionMessage("Package removed.");
|
||||
} else {
|
||||
const messages: Record<Exclude<PluginAction, "remove">, string> = {
|
||||
install: "Package installed.",
|
||||
update: "Package updated.",
|
||||
disable: "Package disabled.",
|
||||
enable: "Package enabled.",
|
||||
};
|
||||
setActionMessage(messages[action]);
|
||||
}
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [cwd]);
|
||||
|
||||
const installPlugin = useCallback(async () => {
|
||||
const source = installSource.trim();
|
||||
if (!source) return;
|
||||
const key = `${installScope}\0${source}`;
|
||||
setBusyKey(`install:${key}`);
|
||||
setActionError(null);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
const res = await fetch("/api/plugins", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "install", source, scope: installScope, cwd }),
|
||||
});
|
||||
const next = (await res.json()) as PluginsResponse & { error?: string };
|
||||
if (!res.ok || next.error) throw new Error(next.error ?? `HTTP ${res.status}`);
|
||||
setData(next);
|
||||
const installed = findInstalledPackage(next.packages, source, installScope);
|
||||
setSelected(installed ? packageKey(installed) : key);
|
||||
setAddMode(false);
|
||||
setInstallSource("");
|
||||
setActionMessage("Package installed.");
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [cwd, installScope, installSource]);
|
||||
|
||||
const reloadSession = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
setBusyKey("reload");
|
||||
setActionError(null);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
await sendAgentCommand(sessionId, { type: "reload" });
|
||||
onReloaded?.();
|
||||
await loadPlugins();
|
||||
setActionMessage("Session reloaded.");
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
}, [loadPlugins, onReloaded, sessionId]);
|
||||
|
||||
const addBusy = busyKey?.startsWith("install:") ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
background: "rgba(0,0,0,0.35)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: isMobile ? "calc(100vw - 16px)" : 860,
|
||||
maxWidth: "calc(100vw - 16px)",
|
||||
height: isMobile ? "calc(100dvh - 16px)" : "76vh",
|
||||
maxHeight: "calc(100dvh - 16px)",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 18px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>
|
||||
{t("common.plugins")}
|
||||
</span>
|
||||
<code
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{shortenPath(cwd)}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 20,
|
||||
lineHeight: 1,
|
||||
padding: "2px 6px",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: isMobile ? "column" : "row", overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
width: isMobile ? "100%" : 245,
|
||||
maxHeight: isMobile ? "40vh" : undefined,
|
||||
borderRight: isMobile ? "none" : "1px solid var(--border)",
|
||||
borderBottom: isMobile ? "1px solid var(--border)" : "none",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexShrink: 0,
|
||||
background: "var(--bg-panel)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: "10px 8px", fontSize: 12, color: "var(--text-muted)" }}>
|
||||
Loading...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: "10px 8px", fontSize: 11, color: "#ef4444" }}>
|
||||
{error}
|
||||
</div>
|
||||
) : packages.length === 0 ? (
|
||||
<div style={{ padding: "10px 8px", fontSize: 11, color: "var(--text-dim)" }}>
|
||||
No plugins configured
|
||||
</div>
|
||||
) : (
|
||||
groupedPackages.map((group) => (
|
||||
<div key={group.scope} style={{ marginBottom: 6 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "4px 8px 3px",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-dim)",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{group.scope === "global" ? "app" : group.scope}
|
||||
</div>
|
||||
{group.packages.map((pkg) => {
|
||||
const key = packageKey(pkg);
|
||||
const isSelected = !addMode && selected === key;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setSelected(key);
|
||||
setAddMode(false);
|
||||
setActionError(null);
|
||||
setActionMessage(null);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "8px 8px",
|
||||
borderRadius: 5,
|
||||
cursor: "pointer",
|
||||
background: isSelected ? "var(--bg-selected)" : "none",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.background = "none";
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: statusColor(pkg.status),
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
color: "var(--text)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{pkg.source}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-dim)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{resourceSummary(pkg, t)}
|
||||
</div>
|
||||
{(pkg.version || pkg.configuredVersion) && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-dim)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{versionSummary(pkg, t)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: "8px 6px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddMode(true);
|
||||
setActionError(null);
|
||||
setActionMessage(null);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "7px 8px",
|
||||
borderRadius: 5,
|
||||
border: "none",
|
||||
width: "100%",
|
||||
cursor: "pointer",
|
||||
background: addMode ? "var(--bg-selected)" : "none",
|
||||
color: addMode ? "var(--accent)" : "var(--text-dim)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!addMode) e.currentTarget.style.background = "var(--bg-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!addMode) e.currentTarget.style.background = "none";
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
{t("i18n.addPlugin")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
|
||||
{addMode ? (
|
||||
<AddPluginPanel
|
||||
cwd={cwd}
|
||||
source={installSource}
|
||||
scope={installScope}
|
||||
busy={addBusy}
|
||||
actionError={actionError}
|
||||
onSourceChange={setInstallSource}
|
||||
onInstall={installPlugin}
|
||||
/>
|
||||
) : loading ? null : selectedPackage ? (
|
||||
<PackageDetail
|
||||
key={packageKey(selectedPackage)}
|
||||
pkg={selectedPackage}
|
||||
cwd={cwd}
|
||||
busyKey={busyKey}
|
||||
actionError={actionError}
|
||||
actionMessage={actionMessage}
|
||||
sessionId={sessionId}
|
||||
onAction={runAction}
|
||||
onReloadSession={reloadSession}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--text-dim)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{t("i18n.selectPackage")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
padding: "10px 18px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1, fontSize: 11, color: "var(--text-dim)", overflow: "hidden" }}>
|
||||
{data?.diagnostics.length ? (
|
||||
<span
|
||||
title={data.diagnostics.map((d) => `${d.type}: ${d.source ? `${d.source}: ` : ""}${d.message}`).join("\n")}
|
||||
style={{ color: data.diagnostics.some((d) => d.type === "error") ? "#ef4444" : "#d97706" }}
|
||||
>
|
||||
{data.diagnostics.length} diagnostic{data.diagnostics.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{data ? `${data.totals.extensions} ext · ${data.totals.skills} skills · ${data.totals.prompts} prompts · ${data.totals.themes} themes` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => void loadPlugins()} disabled={loading || busyKey !== null} style={buttonStyle(loading || busyKey !== null)}>
|
||||
{t("i18n.refresh")}
|
||||
</button>
|
||||
<button onClick={onClose} style={buttonStyle(false)}>
|
||||
{t("i18n.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
export function ProjectTrustDialog({
|
||||
cwd,
|
||||
busy,
|
||||
error,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
cwd: string;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 1100,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 16,
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!busy && event.target === event.currentTarget) onCancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="project-trust-title"
|
||||
style={{
|
||||
width: 440,
|
||||
maxWidth: "100%",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
background: "var(--bg-panel)",
|
||||
boxShadow: "0 12px 36px rgba(0,0,0,0.24)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 12, padding: "18px 18px 14px" }}>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
style={{ flexShrink: 0, marginTop: 1 }}
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div id="project-trust-title" style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>
|
||||
{t("trust.dialogTitle")}
|
||||
</div>
|
||||
<div style={{ marginTop: 7, fontSize: 12, lineHeight: 1.6, color: "var(--text-muted)" }}>
|
||||
{t("trust.dialogBody")}
|
||||
</div>
|
||||
<code
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: 10,
|
||||
padding: "8px 10px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 5,
|
||||
background: "var(--bg)",
|
||||
color: "var(--text)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 11,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{cwd}
|
||||
</code>
|
||||
{error && (
|
||||
<div role="alert" style={{ marginTop: 10, color: "#ef4444", fontSize: 12, lineHeight: 1.5 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
padding: "10px 18px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={busy}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: "0 12px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 5,
|
||||
background: "transparent",
|
||||
color: "var(--text-muted)",
|
||||
cursor: busy ? "not-allowed" : "pointer",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{t("trust.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: "0 12px",
|
||||
border: "1px solid var(--accent)",
|
||||
borderRadius: 5,
|
||||
background: "var(--accent)",
|
||||
color: "white",
|
||||
cursor: busy ? "wait" : "pointer",
|
||||
opacity: busy ? 0.7 : 1,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{busy ? t("trust.trusting") : t("trust.trustProject")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function PwaRegistration() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const register = () => {
|
||||
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION ?? "dev";
|
||||
const scriptUrl = `/sw.js?v=${encodeURIComponent(appVersion)}`;
|
||||
|
||||
void navigator.serviceWorker.register(scriptUrl, {
|
||||
scope: "/",
|
||||
updateViaCache: "none",
|
||||
}).catch((error: unknown) => {
|
||||
console.error("Failed to register the Pi Web service worker:", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "complete") {
|
||||
register();
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("load", register, { once: true });
|
||||
return () => window.removeEventListener("load", register);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const source = await readFile(new URL("./SessionSidebar.tsx", import.meta.url), "utf8");
|
||||
const sessionItemSource = source.slice(source.indexOf("function SessionItem("));
|
||||
|
||||
test("only Shift+click bypasses session deletion confirmation", () => {
|
||||
assert.match(
|
||||
sessionItemSource,
|
||||
/const handleDeleteClick[\s\S]*?if \(e\.shiftKey\) \{\s*void performDelete\(\);\s*\} else \{\s*setConfirmDelete\(true\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not register row-level session deletion shortcuts", () => {
|
||||
assert.doesNotMatch(sessionItemSource, /const handleKeyDown/);
|
||||
assert.doesNotMatch(sessionItemSource, /onKeyDown=\{handleKeyDown\}/);
|
||||
assert.doesNotMatch(sessionItemSource, /tabIndex=\{0\}/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getFileIcon } from "./FileIcons";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
filePath: string;
|
||||
sourceSessionId?: string | null;
|
||||
initialDisplayMode?: "source" | "preview" | "diff";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
onSelectTab: (id: string) => void;
|
||||
onCloseTab: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TabBar({ tabs, activeTabId, onSelectTab, onCloseTab }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [hoveredClose, setHoveredClose] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
background: "var(--bg-panel)",
|
||||
overflowX: "auto",
|
||||
flexShrink: 0,
|
||||
height: 36,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
onClick={() => onSelectTab(tab.id)}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) e.preventDefault();
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button !== 1) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCloseTab(tab.id);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
height: 36,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 6,
|
||||
borderRight: "1px solid var(--border)",
|
||||
background: isActive ? "var(--bg)" : "var(--bg-panel)",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
color: isActive ? "var(--text)" : "var(--text-muted)",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: 180,
|
||||
minWidth: 80,
|
||||
flexShrink: 0,
|
||||
userSelect: "none",
|
||||
transition: "background 0.1s, color 0.1s",
|
||||
}}
|
||||
>
|
||||
<span style={{ flexShrink: 0, opacity: isActive ? 1 : 0.7, display: "flex", alignItems: "center" }}>
|
||||
{getFileIcon(tab.label, 13)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
flex: 1,
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
}}
|
||||
title={tab.filePath}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onCloseTab(tab.id); }}
|
||||
onMouseEnter={() => setHoveredClose(tab.id)}
|
||||
onMouseLeave={() => setHoveredClose(null)}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
width: 24, height: 24,
|
||||
background: hoveredClose === tab.id ? "var(--bg-hover)" : "transparent",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
color: hoveredClose === tab.id ? "var(--text)" : "var(--text-dim)",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
transition: "background 0.1s, color 0.1s",
|
||||
}}
|
||||
title={t("i18n.close")}
|
||||
aria-label={`${t("i18n.close")} ${tab.label}`}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true">
|
||||
<line x1="2" y1="2" x2="8" y2="8" />
|
||||
<line x1="8" y1="2" x2="2" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user