feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+402
View File
@@ -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>
);
}
+87
View File
@@ -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, /&lt;html&gt;request forbidden&lt;\/html&gt;/);
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
+223
View File
@@ -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;
}
+743
View File
@@ -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
+190
View File
@@ -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/);
});
+59
View File
@@ -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>
);
}
+906
View File
@@ -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>
);
});
+249
View File
@@ -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
+85
View File
@@ -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);
});
+102
View File
@@ -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>
);
}
+70
View File
@@ -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, /-&gt;&gt;/);
});
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/);
});
+278
View File
@@ -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>
);
}
+51
View File
@@ -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, /&lt;html&gt;request forbidden&lt;\/html&gt;/);
});
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
+993
View File
@@ -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>
);
}
+146
View File
@@ -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>
);
}
+33
View File
@@ -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;
}
+19
View File
@@ -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
+115
View File
@@ -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>
);
}