mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
68
web/components/a11y/Announcer.tsx
Normal file
68
web/components/a11y/Announcer.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
interface AnnouncerContextValue {
|
||||
announce: (message: string, politeness?: "polite" | "assertive") => void;
|
||||
}
|
||||
|
||||
const AnnouncerContext = createContext<AnnouncerContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Provides a programmatic screen-reader announcement API via context.
|
||||
* Place <AnnouncerProvider> near the root of the app, then call `useAnnouncer()`
|
||||
* anywhere to imperatively announce status changes.
|
||||
*
|
||||
* @example
|
||||
* const { announce } = useAnnouncer();
|
||||
* announce("File uploaded successfully");
|
||||
* announce("Error: request failed", "assertive");
|
||||
*/
|
||||
export function AnnouncerProvider({ children }: { children: ReactNode }) {
|
||||
const [politeMsg, setPoliteMsg] = useState("");
|
||||
const [assertiveMsg, setAssertiveMsg] = useState("");
|
||||
const politeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const assertiveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const announce = useCallback((message: string, politeness: "polite" | "assertive" = "polite") => {
|
||||
if (politeness === "assertive") {
|
||||
setAssertiveMsg("");
|
||||
if (assertiveTimer.current) clearTimeout(assertiveTimer.current);
|
||||
assertiveTimer.current = setTimeout(() => setAssertiveMsg(message), 50);
|
||||
} else {
|
||||
setPoliteMsg("");
|
||||
if (politeTimer.current) clearTimeout(politeTimer.current);
|
||||
politeTimer.current = setTimeout(() => setPoliteMsg(message), 50);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const srStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0,0,0,0)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<AnnouncerContext.Provider value={{ announce }}>
|
||||
{children}
|
||||
<div aria-live="polite" aria-atomic="true" style={srStyle}>
|
||||
{politeMsg}
|
||||
</div>
|
||||
<div aria-live="assertive" aria-atomic="true" style={srStyle}>
|
||||
{assertiveMsg}
|
||||
</div>
|
||||
</AnnouncerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnnouncer() {
|
||||
const ctx = useContext(AnnouncerContext);
|
||||
if (!ctx) throw new Error("useAnnouncer must be used within <AnnouncerProvider>");
|
||||
return ctx;
|
||||
}
|
||||
72
web/components/a11y/FocusTrap.tsx
Normal file
72
web/components/a11y/FocusTrap.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
interface FocusTrapProps {
|
||||
children: ReactNode;
|
||||
/** When false, the trap is inactive (e.g. when the panel is hidden) */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTORS = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(", ");
|
||||
|
||||
/**
|
||||
* Traps keyboard focus within its children when `active` is true.
|
||||
* Use for modals, drawers, and other overlay patterns.
|
||||
* Note: Radix Dialog handles focus trapping natively — use this only for
|
||||
* custom overlay components that don't use Radix primitives.
|
||||
*/
|
||||
export function FocusTrap({ children, active = true }: FocusTrapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const focusable = () =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)).filter(
|
||||
(el) => !el.closest("[aria-hidden='true']")
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const els = focusable();
|
||||
if (els.length === 0) return;
|
||||
|
||||
const first = els[0];
|
||||
const last = els[els.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Move focus into the trap on mount
|
||||
const els = focusable();
|
||||
if (els.length > 0 && !container.contains(document.activeElement)) {
|
||||
els[0].focus();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [active]);
|
||||
|
||||
return <div ref={containerRef}>{children}</div>;
|
||||
}
|
||||
55
web/components/a11y/LiveRegion.tsx
Normal file
55
web/components/a11y/LiveRegion.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface LiveRegionProps {
|
||||
/** The message to announce. Changing this value triggers an announcement. */
|
||||
message: string;
|
||||
/**
|
||||
* "polite" — waits for user to be idle (new chat messages, status updates)
|
||||
* "assertive" — interrupts immediately (errors, critical alerts)
|
||||
*/
|
||||
politeness?: "polite" | "assertive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Managed aria-live region that announces dynamic content to screen readers.
|
||||
* Clears after 500 ms to ensure repeated identical messages are re-announced.
|
||||
*/
|
||||
export function LiveRegion({ message, politeness = "polite" }: LiveRegionProps) {
|
||||
const [announced, setAnnounced] = useState("");
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return;
|
||||
|
||||
// Clear first to force re-announcement of identical messages
|
||||
setAnnounced("");
|
||||
timerRef.current = setTimeout(() => setAnnounced(message), 50);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live={politeness}
|
||||
aria-atomic="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0, 0, 0, 0)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
}}
|
||||
>
|
||||
{announced}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
web/components/a11y/SkipToContent.tsx
Normal file
18
web/components/a11y/SkipToContent.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
export function SkipToContent() {
|
||||
return (
|
||||
<a
|
||||
href="#main-content"
|
||||
className={[
|
||||
"sr-only focus:not-sr-only",
|
||||
"focus:fixed focus:top-4 focus:left-4 focus:z-50",
|
||||
"focus:px-4 focus:py-2 focus:rounded-md",
|
||||
"focus:bg-brand-600 focus:text-white focus:font-medium focus:text-sm",
|
||||
"focus:outline-none focus:ring-2 focus:ring-brand-300 focus:ring-offset-2",
|
||||
].join(" ")}
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
);
|
||||
}
|
||||
31
web/components/a11y/VisuallyHidden.tsx
Normal file
31
web/components/a11y/VisuallyHidden.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface VisuallyHiddenProps {
|
||||
children: ReactNode;
|
||||
/** When true, renders as a span inline; defaults to span */
|
||||
as?: "span" | "div" | "p";
|
||||
}
|
||||
|
||||
/**
|
||||
* Visually hides content while keeping it accessible to screen readers.
|
||||
* Use for icon-only buttons, supplemental context, etc.
|
||||
*/
|
||||
export function VisuallyHidden({ children, as: Tag = "span" }: VisuallyHiddenProps) {
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0, 0, 0, 0)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
170
web/components/adapted/Markdown.tsx
Normal file
170
web/components/adapted/Markdown.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Web-adapted Markdown renderer.
|
||||
*
|
||||
* The terminal Markdown (src/components/Markdown.tsx) uses marked + Ink's
|
||||
* <Ansi> / <Box> to render tokenised markdown as coloured ANSI output.
|
||||
* This web version uses react-markdown + remark-gfm + rehype-highlight, which
|
||||
* are already present in the web package, to render proper HTML with Tailwind
|
||||
* prose styles.
|
||||
*
|
||||
* Props are intentionally compatible with the terminal version so callers can
|
||||
* swap between them via the platform conditional.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MarkdownProps {
|
||||
/** Markdown source string — matches the terminal component's children prop. */
|
||||
children: string;
|
||||
/** When true, render all text as visually dimmed (muted colour). */
|
||||
dimColor?: boolean;
|
||||
/** Extra class names applied to the prose wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Inline code / pre renderers ─────────────────────────────────────────────
|
||||
|
||||
function InlineCode({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<code className="px-1 py-0.5 rounded text-xs font-mono bg-surface-850 text-brand-300 border border-surface-700">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
interface PreProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Pre({ children }: PreProps) {
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-md bg-surface-900 border border-surface-700 p-3 my-2 text-xs font-mono leading-relaxed">
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Markdown({ children, dimColor = false, className }: MarkdownProps) {
|
||||
// Memoised to avoid re-parsing on every parent render.
|
||||
const content = useMemo(() => children, [children]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"markdown-body text-sm leading-relaxed font-mono",
|
||||
dimColor ? "text-surface-500" : "text-surface-100",
|
||||
|
||||
// Headings
|
||||
"[&_h1]:text-base [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-surface-50",
|
||||
"[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mb-1.5 [&_h2]:mt-2.5 [&_h2]:text-surface-100",
|
||||
"[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_h3]:text-surface-200",
|
||||
|
||||
// Paragraphs
|
||||
"[&_p]:my-1 [&_p]:leading-relaxed",
|
||||
|
||||
// Lists
|
||||
"[&_ul]:my-1 [&_ul]:pl-4 [&_ul]:list-disc",
|
||||
"[&_ol]:my-1 [&_ol]:pl-4 [&_ol]:list-decimal",
|
||||
"[&_li]:my-0.5",
|
||||
|
||||
// Blockquote
|
||||
"[&_blockquote]:border-l-2 [&_blockquote]:border-brand-500 [&_blockquote]:pl-3",
|
||||
"[&_blockquote]:my-2 [&_blockquote]:text-surface-400 [&_blockquote]:italic",
|
||||
|
||||
// Horizontal rule
|
||||
"[&_hr]:border-surface-700 [&_hr]:my-3",
|
||||
|
||||
// Tables (GFM)
|
||||
"[&_table]:w-full [&_table]:text-xs [&_table]:border-collapse [&_table]:my-2",
|
||||
"[&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:border [&_th]:border-surface-700 [&_th]:bg-surface-800 [&_th]:font-semibold",
|
||||
"[&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-surface-700",
|
||||
"[&_tr:nth-child(even)_td]:bg-surface-900/40",
|
||||
|
||||
// Links
|
||||
"[&_a]:text-brand-400 [&_a]:no-underline [&_a:hover]:underline",
|
||||
|
||||
// Strong / em
|
||||
"[&_strong]:font-bold [&_strong]:text-surface-50",
|
||||
"[&_em]:italic",
|
||||
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ className: cls, children: codeChildren, ...rest }) {
|
||||
const isBlock = /language-/.test(cls ?? "");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className={cn("block text-surface-200", cls)} {...rest}>
|
||||
{codeChildren}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return <InlineCode {...rest}>{codeChildren}</InlineCode>;
|
||||
},
|
||||
pre: ({ children: preChildren }) => <Pre>{preChildren}</Pre>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Table component (matches MarkdownTable.tsx surface) ─────────────────────
|
||||
|
||||
export interface MarkdownTableProps {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownTable({ headers, rows, className }: MarkdownTableProps) {
|
||||
return (
|
||||
<div className={cn("overflow-x-auto my-2", className)}>
|
||||
<table className="w-full text-xs border-collapse font-mono">
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((h, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="px-2 py-1 text-left border border-surface-700 bg-surface-800 font-semibold text-surface-200"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<td
|
||||
key={ci}
|
||||
className={cn(
|
||||
"px-2 py-1 border border-surface-700 text-surface-300",
|
||||
ri % 2 === 1 && "bg-surface-900/40"
|
||||
)}
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
web/components/adapted/Spinner.tsx
Normal file
151
web/components/adapted/Spinner.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Web-adapted Spinner.
|
||||
*
|
||||
* The terminal Spinner (src/components/Spinner.tsx) drives animation via
|
||||
* useAnimationFrame and renders Unicode braille/block characters with ANSI
|
||||
* colour via Ink's <Text>. In the browser we replace that with a pure-CSS
|
||||
* spinning ring, preserving the same optional `tip` text and `mode` prop
|
||||
* surface so callers can swap in this component without changing props.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Mirrors the SpinnerMode type from src/components/Spinner/index.ts */
|
||||
export type SpinnerMode =
|
||||
| "queued"
|
||||
| "loading"
|
||||
| "thinking"
|
||||
| "auto"
|
||||
| "disabled";
|
||||
|
||||
export interface SpinnerProps {
|
||||
/** Visual mode — controls colour/appearance. */
|
||||
mode?: SpinnerMode;
|
||||
/** Optional tip text shown next to the spinner. */
|
||||
spinnerTip?: string;
|
||||
/** Override message replaces the default verb. */
|
||||
overrideMessage?: string | null;
|
||||
/** Additional suffix appended after the main label. */
|
||||
spinnerSuffix?: string | null;
|
||||
/** When true the spinner renders inline instead of as a block row. */
|
||||
inline?: boolean;
|
||||
/** Extra class names for the wrapper element. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Colour map ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MODE_RING_CLASS: Record<SpinnerMode, string> = {
|
||||
queued: "border-surface-500",
|
||||
loading: "border-brand-400",
|
||||
thinking: "border-brand-500",
|
||||
auto: "border-brand-400",
|
||||
disabled: "border-surface-600",
|
||||
};
|
||||
|
||||
const MODE_TEXT_CLASS: Record<SpinnerMode, string> = {
|
||||
queued: "text-surface-400",
|
||||
loading: "text-brand-300",
|
||||
thinking: "text-brand-300",
|
||||
auto: "text-brand-300",
|
||||
disabled: "text-surface-500",
|
||||
};
|
||||
|
||||
const MODE_LABEL: Record<SpinnerMode, string> = {
|
||||
queued: "Queued…",
|
||||
loading: "Loading…",
|
||||
thinking: "Thinking…",
|
||||
auto: "Working…",
|
||||
disabled: "",
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Spinner({
|
||||
mode = "loading",
|
||||
spinnerTip,
|
||||
overrideMessage,
|
||||
spinnerSuffix,
|
||||
inline = false,
|
||||
className,
|
||||
}: SpinnerProps) {
|
||||
if (mode === "disabled") return null;
|
||||
|
||||
const label =
|
||||
overrideMessage ??
|
||||
spinnerTip ??
|
||||
MODE_LABEL[mode];
|
||||
|
||||
const ringClass = MODE_RING_CLASS[mode];
|
||||
const textClass = MODE_TEXT_CLASS[mode];
|
||||
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label || "Loading"}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
inline ? "inline-flex" : "flex",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* CSS spinning ring */}
|
||||
<span
|
||||
className={cn(
|
||||
"block w-3.5 h-3.5 rounded-full border-2 border-transparent animate-spin flex-shrink-0",
|
||||
ringClass,
|
||||
// Top border only — creates the "gap" in the ring for the spinning effect
|
||||
"[border-top-color:currentColor]"
|
||||
)}
|
||||
style={{ borderTopColor: undefined }}
|
||||
aria-hidden
|
||||
>
|
||||
{/* Inner ring for the visible arc — achieved via box-shadow trick */}
|
||||
</span>
|
||||
|
||||
{(label || spinnerSuffix) && (
|
||||
<span className={cn("text-sm font-mono", textClass)}>
|
||||
{label}
|
||||
{spinnerSuffix && (
|
||||
<span className="text-surface-500 ml-1">{spinnerSuffix}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shimmer / glimmer variant ────────────────────────────────────────────────
|
||||
|
||||
/** Pulsing shimmer bar — web replacement for GlimmerMessage / ShimmerChar. */
|
||||
export function ShimmerBar({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 rounded-full bg-gradient-to-r from-surface-700 via-surface-500 to-surface-700",
|
||||
"bg-[length:200%_100%] animate-shimmer",
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline flashing cursor dot — web replacement for FlashingChar. */
|
||||
export function FlashingCursor({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block w-1.5 h-4 bg-current align-text-bottom ml-0.5",
|
||||
"animate-pulse-soft",
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
180
web/components/chat/ChatInput.tsx
Normal file
180
web/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Send, Square, Paperclip } from "lucide-react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { streamChat } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MAX_MESSAGE_LENGTH } from "@/lib/constants";
|
||||
|
||||
interface ChatInputProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export function ChatInput({ conversationId }: ChatInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const { conversations, settings, addMessage, updateMessage } = useChatStore();
|
||||
const conversation = conversations.find((c) => c.id === conversationId);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isStreaming) return;
|
||||
|
||||
setInput("");
|
||||
setIsStreaming(true);
|
||||
|
||||
// Add user message
|
||||
addMessage(conversationId, {
|
||||
role: "user",
|
||||
content: text,
|
||||
status: "complete",
|
||||
});
|
||||
|
||||
// Add placeholder assistant message
|
||||
const assistantId = addMessage(conversationId, {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
status: "streaming",
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const messages = [
|
||||
...(conversation?.messages ?? []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
{ role: "user" as const, content: text },
|
||||
];
|
||||
|
||||
let fullText = "";
|
||||
|
||||
try {
|
||||
for await (const chunk of streamChat(messages, settings.model, controller.signal)) {
|
||||
if (chunk.type === "text" && chunk.content) {
|
||||
fullText += chunk.content;
|
||||
updateMessage(conversationId, assistantId, {
|
||||
content: fullText,
|
||||
status: "streaming",
|
||||
});
|
||||
} else if (chunk.type === "done") {
|
||||
break;
|
||||
} else if (chunk.type === "error") {
|
||||
updateMessage(conversationId, assistantId, {
|
||||
content: chunk.error ?? "An error occurred",
|
||||
status: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
updateMessage(conversationId, assistantId, { status: "complete" });
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
updateMessage(conversationId, assistantId, {
|
||||
content: "Request failed. Please try again.",
|
||||
status: "error",
|
||||
});
|
||||
} else {
|
||||
updateMessage(conversationId, assistantId, { status: "complete" });
|
||||
}
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [input, isStreaming, conversationId, conversation, settings.model, addMessage, updateMessage]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
|
||||
const adjustHeight = () => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-surface-800 bg-surface-900/50 backdrop-blur-sm px-4 py-3">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 rounded-xl border bg-surface-800 px-3 py-2",
|
||||
"border-surface-700 focus-within:border-brand-500 transition-colors"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className="p-1 text-surface-500 hover:text-surface-300 transition-colors flex-shrink-0 mb-0.5"
|
||||
aria-label="Attach file"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<label htmlFor="chat-input" className="sr-only">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="chat-input"
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value.slice(0, MAX_MESSAGE_LENGTH));
|
||||
adjustHeight();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message Claude Code..."
|
||||
rows={1}
|
||||
aria-label="Message"
|
||||
className={cn(
|
||||
"flex-1 resize-none bg-transparent text-sm text-surface-100",
|
||||
"placeholder:text-surface-500 focus:outline-none",
|
||||
"min-h-[24px] max-h-[200px] py-0.5"
|
||||
)}
|
||||
/>
|
||||
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
aria-label="Stop generation"
|
||||
className="p-1.5 rounded-lg bg-surface-700 text-surface-300 hover:bg-surface-600 transition-colors flex-shrink-0"
|
||||
>
|
||||
<Square className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim()}
|
||||
aria-label="Send message"
|
||||
aria-disabled={!input.trim()}
|
||||
className={cn(
|
||||
"p-1.5 rounded-lg transition-colors flex-shrink-0",
|
||||
input.trim()
|
||||
? "bg-brand-600 text-white hover:bg-brand-700"
|
||||
: "bg-surface-700 text-surface-500 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Send className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-surface-600 text-center mt-2">
|
||||
Claude can make mistakes. Verify important information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
web/components/chat/ChatLayout.tsx
Normal file
48
web/components/chat/ChatLayout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { Header } from "@/components/layout/Header";
|
||||
import { ChatWindow } from "./ChatWindow";
|
||||
import { ChatInput } from "./ChatInput";
|
||||
import { SkipToContent } from "@/components/a11y/SkipToContent";
|
||||
import { AnnouncerProvider } from "@/components/a11y/Announcer";
|
||||
|
||||
export function ChatLayout() {
|
||||
const { conversations, createConversation, activeConversationId } = useChatStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (conversations.length === 0) {
|
||||
createConversation();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnnouncerProvider>
|
||||
<SkipToContent />
|
||||
<div className="flex h-screen bg-surface-950 text-surface-100">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Header />
|
||||
<main
|
||||
id="main-content"
|
||||
aria-label="Chat"
|
||||
className="flex flex-col flex-1 min-h-0"
|
||||
>
|
||||
{activeConversationId ? (
|
||||
<>
|
||||
<ChatWindow conversationId={activeConversationId} />
|
||||
<ChatInput conversationId={activeConversationId} />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-surface-500">
|
||||
Select or create a conversation
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</AnnouncerProvider>
|
||||
);
|
||||
}
|
||||
94
web/components/chat/ChatWindow.tsx
Normal file
94
web/components/chat/ChatWindow.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { Bot } from "lucide-react";
|
||||
|
||||
interface ChatWindowProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export function ChatWindow({ conversationId }: ChatWindowProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const { conversations } = useChatStore();
|
||||
const conversation = conversations.find((c) => c.id === conversationId);
|
||||
const messages = conversation?.messages ?? [];
|
||||
|
||||
const isStreaming = messages.some((m) => m.status === "streaming");
|
||||
|
||||
// Announce the last completed assistant message to screen readers
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const prevLengthRef = useRef(messages.length);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (
|
||||
messages.length > prevLengthRef.current &&
|
||||
lastMsg?.role === "assistant" &&
|
||||
lastMsg.status === "complete"
|
||||
) {
|
||||
// Announce a short preview so screen reader users know a reply arrived
|
||||
const preview = lastMsg.content.slice(0, 100);
|
||||
setAnnouncement("");
|
||||
setTimeout(() => setAnnouncement(`Claude replied: ${preview}`), 50);
|
||||
}
|
||||
prevLengthRef.current = messages.length;
|
||||
}, [messages.length, messages]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-6">
|
||||
<div
|
||||
className="w-12 h-12 rounded-full bg-brand-600/20 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Bot className="w-6 h-6 text-brand-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-surface-100">How can I help?</h2>
|
||||
<p className="text-sm text-surface-400 mt-1">
|
||||
Start a conversation with Claude Code
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
aria-busy={isStreaming}
|
||||
aria-label="Conversation"
|
||||
>
|
||||
{/* Polite live region — announces when Claude finishes a reply */}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: "1px",
|
||||
height: "1px",
|
||||
padding: 0,
|
||||
margin: "-1px",
|
||||
overflow: "hidden",
|
||||
clip: "rect(0,0,0,0)",
|
||||
whiteSpace: "nowrap",
|
||||
borderWidth: 0,
|
||||
}}
|
||||
>
|
||||
{announcement}
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl mx-auto py-6 px-4 space-y-6">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
<div ref={bottomRef} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
web/components/chat/MarkdownContent.tsx
Normal file
32
web/components/chat/MarkdownContent.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose prose-sm prose-invert max-w-none",
|
||||
"prose-p:leading-relaxed prose-p:my-1",
|
||||
"prose-pre:bg-surface-900 prose-pre:border prose-pre:border-surface-700",
|
||||
"prose-code:text-brand-300 prose-code:bg-surface-900 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs",
|
||||
"prose-pre:code:bg-transparent prose-pre:code:text-surface-100 prose-pre:code:p-0",
|
||||
"prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5",
|
||||
"prose-headings:text-surface-100 prose-headings:font-semibold",
|
||||
"prose-a:text-brand-400 prose-a:no-underline hover:prose-a:underline",
|
||||
"prose-blockquote:border-brand-500 prose-blockquote:text-surface-300",
|
||||
"prose-hr:border-surface-700",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
web/components/chat/MessageBubble.tsx
Normal file
78
web/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { User, Bot, AlertCircle } from "lucide-react";
|
||||
import { cn, extractTextContent } from "@/lib/utils";
|
||||
import type { Message } from "@/lib/types";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isUser = message.role === "user";
|
||||
const isError = message.status === "error";
|
||||
const text = extractTextContent(message.content);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
"flex gap-3 animate-fade-in",
|
||||
isUser && "flex-row-reverse"
|
||||
)}
|
||||
aria-label={isUser ? "You" : isError ? "Error from Claude" : "Claude"}
|
||||
>
|
||||
{/* Avatar — purely decorative, role conveyed by article label */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5",
|
||||
isUser
|
||||
? "bg-brand-600 text-white"
|
||||
: isError
|
||||
? "bg-red-900 text-red-300"
|
||||
: "bg-surface-700 text-surface-300"
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<User className="w-4 h-4" aria-hidden="true" />
|
||||
) : isError ? (
|
||||
<AlertCircle className="w-4 h-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Bot className="w-4 h-4" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-w-0 max-w-2xl",
|
||||
isUser && "flex justify-end"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl px-4 py-3 text-sm",
|
||||
isUser
|
||||
? "bg-brand-600 text-white rounded-tr-sm"
|
||||
: isError
|
||||
? "bg-red-950 border border-red-800 text-red-200 rounded-tl-sm"
|
||||
: "bg-surface-800 text-surface-100 rounded-tl-sm"
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap break-words">{text}</p>
|
||||
) : (
|
||||
<MarkdownContent content={text} />
|
||||
)}
|
||||
{message.status === "streaming" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block w-1.5 h-4 bg-current ml-0.5 animate-pulse-soft"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
114
web/components/chat/VirtualMessageList.tsx
Normal file
114
web/components/chat/VirtualMessageList.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useCallback } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { Message } from "@/lib/types";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
|
||||
/**
|
||||
* Estimated heights used for initial layout. The virtualizer measures actual
|
||||
* heights after render and updates scroll positions accordingly.
|
||||
*/
|
||||
const ESTIMATED_HEIGHT = {
|
||||
short: 80, // typical user message
|
||||
medium: 160, // short assistant reply
|
||||
tall: 320, // code blocks / long replies
|
||||
};
|
||||
|
||||
function estimateMessageHeight(message: Message): number {
|
||||
const text =
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("");
|
||||
|
||||
if (text.length < 100) return ESTIMATED_HEIGHT.short;
|
||||
if (text.length < 500 || text.includes("```")) return ESTIMATED_HEIGHT.medium;
|
||||
return ESTIMATED_HEIGHT.tall;
|
||||
}
|
||||
|
||||
interface VirtualMessageListProps {
|
||||
messages: Message[];
|
||||
/** Whether streaming is in progress — suppresses smooth-scroll so the
|
||||
* autoscroll keeps up with incoming tokens. */
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function VirtualMessageList({ messages, isStreaming }: VirtualMessageListProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: messages.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (index) => estimateMessageHeight(messages[index]),
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
// Track whether the user has scrolled away from the bottom
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
isAtBottomRef.current = distanceFromBottom < 80;
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive (if already at bottom)
|
||||
useEffect(() => {
|
||||
if (!isAtBottomRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (isStreaming) {
|
||||
// Instant scroll during streaming to keep up with tokens
|
||||
el.scrollTop = el.scrollHeight;
|
||||
} else {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
}, [messages.length, isStreaming]);
|
||||
|
||||
// Also scroll when the last streaming message content changes
|
||||
useEffect(() => {
|
||||
if (!isStreaming || !isAtBottomRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
|
||||
const items = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{/* Spacer that gives the virtualizer its total height */}
|
||||
<div
|
||||
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
|
||||
className="max-w-3xl mx-auto px-4 py-6"
|
||||
>
|
||||
{items.map((virtualItem) => {
|
||||
const message = messages[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
className="pb-6"
|
||||
>
|
||||
<MessageBubble message={message} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
web/components/collaboration/AnnotationBadge.tsx
Normal file
50
web/components/collaboration/AnnotationBadge.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnnotationThread } from "./AnnotationThread";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
|
||||
interface AnnotationBadgeProps {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export function AnnotationBadge({ messageId }: AnnotationBadgeProps) {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!ctx) return null;
|
||||
|
||||
const annotations = ctx.annotations[messageId] ?? [];
|
||||
const unresolved = annotations.filter((a) => !a.resolved);
|
||||
if (annotations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium",
|
||||
"transition-colors border",
|
||||
unresolved.length > 0
|
||||
? "bg-amber-900/30 border-amber-700/50 text-amber-300 hover:bg-amber-900/50"
|
||||
: "bg-surface-800 border-surface-700 text-surface-400 hover:bg-surface-700"
|
||||
)}
|
||||
title={`${annotations.length} comment${annotations.length !== 1 ? "s" : ""}`}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{unresolved.length > 0 ? unresolved.length : annotations.length}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 z-40 w-80"
|
||||
onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
|
||||
>
|
||||
<AnnotationThread messageId={messageId} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
web/components/collaboration/CollaborationProvider.tsx
Normal file
139
web/components/collaboration/CollaborationProvider.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useRef, useMemo } from "react";
|
||||
import { useCollaboration } from "@/hooks/useCollaboration";
|
||||
import { usePresence } from "@/hooks/usePresence";
|
||||
import { CollabSocket } from "@/lib/collaboration/socket";
|
||||
import type { CollabUser, CollabRole } from "@/lib/collaboration/socket";
|
||||
import type { CollabAnnotation, PendingToolUse } from "@/lib/collaboration/types";
|
||||
import type { PresenceState } from "@/lib/collaboration/presence";
|
||||
import type { LinkExpiry, ShareLink } from "@/lib/collaboration/types";
|
||||
import { createShareLink } from "@/lib/collaboration/permissions";
|
||||
|
||||
// ─── Context Shape ────────────────────────────────────────────────────────────
|
||||
|
||||
interface CollaborationContextValue {
|
||||
// Connection
|
||||
isConnected: boolean;
|
||||
sessionId: string;
|
||||
currentUser: CollabUser;
|
||||
|
||||
// Roles & policy
|
||||
myRole: CollabRole | null;
|
||||
toolApprovalPolicy: "owner-only" | "any-collaborator";
|
||||
|
||||
// Presence
|
||||
presence: PresenceState;
|
||||
otherUsers: CollabUser[];
|
||||
typingUsers: CollabUser[];
|
||||
|
||||
// Tool approvals
|
||||
pendingToolUses: PendingToolUse[];
|
||||
approveTool: (toolUseId: string) => void;
|
||||
denyTool: (toolUseId: string) => void;
|
||||
|
||||
// Annotations
|
||||
annotations: Record<string, CollabAnnotation[]>;
|
||||
addAnnotation: (messageId: string, text: string) => void;
|
||||
resolveAnnotation: (annotationId: string, resolved: boolean) => void;
|
||||
replyAnnotation: (annotationId: string, text: string) => void;
|
||||
|
||||
// Presence actions
|
||||
sendCursorUpdate: (pos: number, start?: number, end?: number) => void;
|
||||
notifyTyping: () => void;
|
||||
stopTyping: () => void;
|
||||
|
||||
// Session management
|
||||
generateShareLink: (role: CollabRole, expiry: LinkExpiry) => ShareLink;
|
||||
revokeAccess: (userId: string) => void;
|
||||
changeRole: (userId: string, role: CollabRole) => void;
|
||||
transferOwnership: (userId: string) => void;
|
||||
}
|
||||
|
||||
const CollaborationContext = createContext<CollaborationContextValue | null>(null);
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CollaborationProviderProps {
|
||||
sessionId: string;
|
||||
currentUser: CollabUser;
|
||||
wsUrl?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CollaborationProvider({
|
||||
sessionId,
|
||||
currentUser,
|
||||
wsUrl,
|
||||
children,
|
||||
}: CollaborationProviderProps) {
|
||||
const socketRef = useRef<CollabSocket | null>(null);
|
||||
|
||||
const collab = useCollaboration({ sessionId, currentUser, wsUrl });
|
||||
|
||||
// Access the socket from the ref (set by the hook internally)
|
||||
// Since useCollaboration creates the socket internally, we expose a proxy
|
||||
// via the presence hook's socket param by reaching into the hook return
|
||||
const presence = usePresence({
|
||||
socket: socketRef.current,
|
||||
sessionId,
|
||||
currentUser,
|
||||
});
|
||||
|
||||
const generateShareLink = useMemo(
|
||||
() => (role: CollabRole, expiry: LinkExpiry) =>
|
||||
createShareLink(sessionId, role, expiry, currentUser.id),
|
||||
[sessionId, currentUser.id]
|
||||
);
|
||||
|
||||
const value: CollaborationContextValue = {
|
||||
isConnected: collab.isConnected,
|
||||
sessionId,
|
||||
currentUser,
|
||||
myRole: collab.myRole,
|
||||
toolApprovalPolicy: collab.toolApprovalPolicy,
|
||||
presence: presence.presence,
|
||||
otherUsers: presence.otherUsers,
|
||||
typingUsers: presence.typingUsers,
|
||||
pendingToolUses: collab.pendingToolUses,
|
||||
approveTool: collab.approveTool,
|
||||
denyTool: collab.denyTool,
|
||||
annotations: collab.annotations,
|
||||
addAnnotation: collab.addAnnotation,
|
||||
resolveAnnotation: collab.resolveAnnotation,
|
||||
replyAnnotation: collab.replyAnnotation,
|
||||
sendCursorUpdate: presence.sendCursorUpdate,
|
||||
notifyTyping: presence.notifyTyping,
|
||||
stopTyping: presence.stopTyping,
|
||||
generateShareLink,
|
||||
revokeAccess: collab.revokeAccess,
|
||||
changeRole: collab.changeRole,
|
||||
transferOwnership: collab.transferOwnership,
|
||||
};
|
||||
|
||||
return (
|
||||
<CollaborationContext.Provider value={value}>
|
||||
{children}
|
||||
</CollaborationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Consumer Hook ────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCollaborationContext(): CollaborationContextValue {
|
||||
const ctx = useContext(CollaborationContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useCollaborationContext must be used inside <CollaborationProvider>"
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns null when there is no active collaboration session.
|
||||
* Use this in components that render outside a CollaborationProvider.
|
||||
*/
|
||||
export function useCollaborationContextOptional(): CollaborationContextValue | null {
|
||||
return useContext(CollaborationContext);
|
||||
}
|
||||
122
web/components/collaboration/CursorGhost.tsx
Normal file
122
web/components/collaboration/CursorGhost.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
import type { CursorState } from "@/lib/collaboration/presence";
|
||||
import type { CollabUser } from "@/lib/collaboration/socket";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CursorGhostProps {
|
||||
/** The textarea ref to measure cursor positions against */
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||
}
|
||||
|
||||
interface RenderedCursor {
|
||||
user: CollabUser;
|
||||
cursor: CursorState;
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Approximates pixel position of a text offset inside a textarea.
|
||||
* Uses a hidden mirror div that matches the textarea's styling.
|
||||
*/
|
||||
function measureCursorPosition(
|
||||
textarea: HTMLTextAreaElement,
|
||||
offset: number
|
||||
): { top: number; left: number } {
|
||||
const mirror = document.createElement("div");
|
||||
const computed = window.getComputedStyle(textarea);
|
||||
|
||||
mirror.style.position = "absolute";
|
||||
mirror.style.visibility = "hidden";
|
||||
mirror.style.whiteSpace = "pre-wrap";
|
||||
mirror.style.wordWrap = "break-word";
|
||||
mirror.style.width = computed.width;
|
||||
mirror.style.font = computed.font;
|
||||
mirror.style.lineHeight = computed.lineHeight;
|
||||
mirror.style.padding = computed.padding;
|
||||
mirror.style.border = computed.border;
|
||||
mirror.style.boxSizing = computed.boxSizing;
|
||||
|
||||
const text = textarea.value.slice(0, offset);
|
||||
mirror.textContent = text;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "\u200b"; // zero-width space
|
||||
mirror.appendChild(span);
|
||||
|
||||
document.body.appendChild(mirror);
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
document.body.removeChild(mirror);
|
||||
|
||||
return {
|
||||
top: spanRect.top - rect.top + textarea.scrollTop,
|
||||
left: spanRect.left - rect.left,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── CursorGhost ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function CursorGhost({ textareaRef }: CursorGhostProps) {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
const [rendered, setRendered] = useState<RenderedCursor[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx || !textareaRef.current) return;
|
||||
const textarea = textareaRef.current;
|
||||
const { presence, otherUsers } = ctx;
|
||||
|
||||
const next: RenderedCursor[] = [];
|
||||
for (const user of otherUsers) {
|
||||
const cursor = presence.cursors.get(user.id);
|
||||
if (!cursor) continue;
|
||||
try {
|
||||
const pos = measureCursorPosition(textarea, cursor.position);
|
||||
next.push({ user, cursor, ...pos });
|
||||
} catch {
|
||||
// ignore measurement errors
|
||||
}
|
||||
}
|
||||
setRendered(next);
|
||||
});
|
||||
|
||||
if (!ctx || rendered.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<AnimatePresence>
|
||||
{rendered.map(({ user, top, left }) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute flex flex-col items-start"
|
||||
style={{ top, left }}
|
||||
>
|
||||
{/* Cursor caret */}
|
||||
<div
|
||||
className="w-0.5 h-4"
|
||||
style={{ backgroundColor: user.color }}
|
||||
/>
|
||||
{/* Name tag */}
|
||||
<div
|
||||
className="px-1 py-0.5 rounded text-[9px] font-semibold text-white whitespace-nowrap"
|
||||
style={{ backgroundColor: user.color }}
|
||||
>
|
||||
{user.name}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
web/components/collaboration/PresenceAvatars.tsx
Normal file
136
web/components/collaboration/PresenceAvatars.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { getInitials } from "@/lib/collaboration/presence";
|
||||
import { labelForRole } from "@/lib/collaboration/permissions";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Single Avatar ────────────────────────────────────────────────────────────
|
||||
|
||||
interface AvatarProps {
|
||||
name: string;
|
||||
color: string;
|
||||
avatar?: string;
|
||||
role: import("@/lib/collaboration/socket").CollabRole;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
function UserAvatar({ name, color, avatar, role, isActive = true }: AvatarProps) {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={300}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div
|
||||
className="relative w-7 h-7 rounded-full flex-shrink-0 cursor-default select-none"
|
||||
style={{ boxShadow: `0 0 0 2px ${color}` }}
|
||||
>
|
||||
{avatar ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={avatar}
|
||||
alt={name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full rounded-full flex items-center justify-center text-[10px] font-semibold text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Online indicator dot */}
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 right-0 w-2 h-2 rounded-full bg-green-400 border border-surface-900" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"z-50 rounded-md px-2.5 py-1.5 text-xs shadow-md",
|
||||
"bg-surface-800 border border-surface-700 text-surface-100"
|
||||
)}
|
||||
>
|
||||
<p className="font-medium">{name}</p>
|
||||
<p className="text-surface-400">{labelForRole(role)}</p>
|
||||
<Tooltip.Arrow className="fill-surface-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PresenceAvatars ──────────────────────────────────────────────────────────
|
||||
|
||||
export function PresenceAvatars() {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
if (!ctx) return null;
|
||||
|
||||
const { isConnected, otherUsers, currentUser } = ctx;
|
||||
// Show at most 4 avatars + overflow badge
|
||||
const MAX_VISIBLE = 4;
|
||||
const allUsers = [currentUser, ...otherUsers];
|
||||
const visible = allUsers.slice(0, MAX_VISIBLE);
|
||||
const overflow = allUsers.length - MAX_VISIBLE;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Connection indicator */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isConnected ? (
|
||||
<Wifi className="w-3.5 h-3.5 text-green-400" />
|
||||
) : (
|
||||
<WifiOff className="w-3.5 h-3.5 text-surface-500 animate-pulse" />
|
||||
)}
|
||||
<span className="text-xs text-surface-500 hidden sm:inline">
|
||||
{isConnected
|
||||
? `${allUsers.length} online`
|
||||
: "Reconnecting…"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stacked avatars */}
|
||||
<div className="flex items-center">
|
||||
<AnimatePresence>
|
||||
{visible.map((user, i) => (
|
||||
<motion.div
|
||||
key={user.id}
|
||||
initial={{ opacity: 0, scale: 0.5, x: -8 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={{ duration: 0.2, delay: i * 0.04 }}
|
||||
style={{ zIndex: visible.length - i, marginLeft: i === 0 ? 0 : -8 }}
|
||||
>
|
||||
<UserAvatar
|
||||
name={user.id === currentUser.id ? `${user.name} (you)` : user.name}
|
||||
color={user.color}
|
||||
avatar={user.avatar}
|
||||
role={user.role}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{overflow > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-7 h-7 rounded-full -ml-2 z-0 flex items-center justify-center",
|
||||
"bg-surface-700 border-2 border-surface-900 text-[10px] font-medium text-surface-300"
|
||||
)}
|
||||
>
|
||||
+{overflow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
web/components/collaboration/TypingIndicator.tsx
Normal file
73
web/components/collaboration/TypingIndicator.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useCollaborationContextOptional } from "./CollaborationProvider";
|
||||
|
||||
// ─── Animated dots ────────────────────────────────────────────────────────────
|
||||
|
||||
function Dots() {
|
||||
return (
|
||||
<span className="inline-flex items-end gap-0.5 h-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="w-1 h-1 rounded-full bg-surface-400 inline-block"
|
||||
animate={{ y: [0, -3, 0] }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.15,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TypingIndicator ──────────────────────────────────────────────────────────
|
||||
|
||||
export function TypingIndicator() {
|
||||
const ctx = useCollaborationContextOptional();
|
||||
if (!ctx) return null;
|
||||
|
||||
const { typingUsers } = ctx;
|
||||
if (typingUsers.length === 0) return null;
|
||||
|
||||
let label: string;
|
||||
if (typingUsers.length === 1) {
|
||||
label = `${typingUsers[0].name} is typing`;
|
||||
} else if (typingUsers.length === 2) {
|
||||
label = `${typingUsers[0].name} and ${typingUsers[1].name} are typing`;
|
||||
} else {
|
||||
label = `${typingUsers.length} people are typing`;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="typing-indicator"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex items-center gap-1.5 px-4 pb-1 text-xs text-surface-400"
|
||||
>
|
||||
{/* Colored dots for each typing user */}
|
||||
<span className="flex -space-x-1">
|
||||
{typingUsers.slice(0, 3).map((u) => (
|
||||
<span
|
||||
key={u.id}
|
||||
className="w-4 h-4 rounded-full border border-surface-900 flex items-center justify-center text-[8px] font-bold text-white"
|
||||
style={{ backgroundColor: u.color }}
|
||||
>
|
||||
{u.name[0].toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
<Dots />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
249
web/components/command-palette/CommandPalette.tsx
Normal file
249
web/components/command-palette/CommandPalette.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { Search, Clock } from "lucide-react";
|
||||
import { useCommandRegistry } from "@/hooks/useCommandRegistry";
|
||||
import { CommandPaletteItem } from "./CommandPaletteItem";
|
||||
import { SHORTCUT_CATEGORIES } from "@/lib/shortcuts";
|
||||
import type { Command, ShortcutCategory } from "@/lib/shortcuts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Fuzzy match: every character of query must appear in order in target */
|
||||
function fuzzyMatch(target: string, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const t = target.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
let qi = 0;
|
||||
for (let i = 0; i < t.length && qi < q.length; i++) {
|
||||
if (t[i] === q[qi]) qi++;
|
||||
}
|
||||
return qi === q.length;
|
||||
}
|
||||
|
||||
/** Score a command against a search query (higher = better) */
|
||||
function score(cmd: Command, query: string): number {
|
||||
const q = query.toLowerCase();
|
||||
const label = cmd.label.toLowerCase();
|
||||
if (label === q) return 100;
|
||||
if (label.startsWith(q)) return 80;
|
||||
if (label.includes(q)) return 60;
|
||||
if (cmd.description.toLowerCase().includes(q)) return 40;
|
||||
if (fuzzyMatch(label, q)) return 20;
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface GroupedResults {
|
||||
label: string;
|
||||
commands: Command[];
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const {
|
||||
paletteOpen,
|
||||
closePalette,
|
||||
commands,
|
||||
runCommand,
|
||||
recentCommandIds,
|
||||
openHelp,
|
||||
} = useCommandRegistry();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Reset state when palette opens
|
||||
useEffect(() => {
|
||||
if (paletteOpen) {
|
||||
setQuery("");
|
||||
setActiveIndex(0);
|
||||
// Small delay to let the dialog animate in before focusing
|
||||
setTimeout(() => inputRef.current?.focus(), 10);
|
||||
}
|
||||
}, [paletteOpen]);
|
||||
|
||||
const filteredGroups = useMemo<GroupedResults[]>(() => {
|
||||
if (!query.trim()) {
|
||||
// Show recents first, then all categories
|
||||
const recentCmds = recentCommandIds
|
||||
.map((id) => commands.find((c) => c.id === id))
|
||||
.filter((c): c is Command => !!c);
|
||||
|
||||
const groups: GroupedResults[] = [];
|
||||
if (recentCmds.length > 0) {
|
||||
groups.push({ label: "Recent", commands: recentCmds });
|
||||
}
|
||||
for (const cat of SHORTCUT_CATEGORIES) {
|
||||
const catCmds = commands.filter((c) => c.category === cat);
|
||||
if (catCmds.length > 0) {
|
||||
groups.push({ label: cat, commands: catCmds });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// Search mode: flat scored list, re-grouped by category
|
||||
const scored = commands
|
||||
.map((cmd) => ({ cmd, s: score(cmd, query) }))
|
||||
.filter(({ s }) => s > 0)
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.map(({ cmd }) => cmd);
|
||||
|
||||
if (scored.length === 0) return [];
|
||||
|
||||
const byCategory: Partial<Record<ShortcutCategory, Command[]>> = {};
|
||||
for (const cmd of scored) {
|
||||
if (!byCategory[cmd.category]) byCategory[cmd.category] = [];
|
||||
byCategory[cmd.category]!.push(cmd);
|
||||
}
|
||||
|
||||
return SHORTCUT_CATEGORIES.filter((c) => byCategory[c]?.length).map(
|
||||
(c) => ({ label: c, commands: byCategory[c]! })
|
||||
);
|
||||
}, [query, commands, recentCommandIds]);
|
||||
|
||||
const flatResults = useMemo(
|
||||
() => filteredGroups.flatMap((g) => g.commands),
|
||||
[filteredGroups]
|
||||
);
|
||||
|
||||
// Clamp activeIndex when results change
|
||||
useEffect(() => {
|
||||
setActiveIndex((i) => Math.min(i, Math.max(flatResults.length - 1, 0)));
|
||||
}, [flatResults.length]);
|
||||
|
||||
// Scroll active item into view
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector(`[data-index="${activeIndex}"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, flatResults.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const cmd = flatResults[activeIndex];
|
||||
if (cmd) {
|
||||
closePalette();
|
||||
runCommand(cmd.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (cmd: Command) => {
|
||||
closePalette();
|
||||
runCommand(cmd.id);
|
||||
};
|
||||
|
||||
let flatIdx = 0;
|
||||
|
||||
return (
|
||||
<Dialog.Root open={paletteOpen} onOpenChange={(open) => !open && closePalette()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<Dialog.Content
|
||||
className={cn(
|
||||
"fixed left-1/2 top-[20%] -translate-x-1/2 z-50",
|
||||
"w-full max-w-xl",
|
||||
"bg-surface-900 border border-surface-700 rounded-xl shadow-2xl",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:slide-out-to-left-1/2 data-[state=open]:slide-in-from-left-1/2",
|
||||
"data-[state=closed]:slide-out-to-top-[18%] data-[state=open]:slide-in-from-top-[18%]"
|
||||
)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label="Command palette"
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-2 px-3 py-3 border-b border-surface-800">
|
||||
<Search className="w-4 h-4 text-surface-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
placeholder="Search commands..."
|
||||
className={cn(
|
||||
"flex-1 bg-transparent text-sm text-surface-100",
|
||||
"placeholder:text-surface-500 focus:outline-none"
|
||||
)}
|
||||
/>
|
||||
<kbd className="hidden sm:inline-flex items-center h-5 px-1.5 rounded text-[10px] font-mono bg-surface-800 border border-surface-700 text-surface-500">
|
||||
Esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="overflow-y-auto max-h-[360px] py-1"
|
||||
>
|
||||
{filteredGroups.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-surface-500">
|
||||
No commands found
|
||||
</div>
|
||||
) : (
|
||||
filteredGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5">
|
||||
{group.label === "Recent" && (
|
||||
<Clock className="w-3 h-3 text-surface-600" />
|
||||
)}
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-surface-600">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.commands.map((cmd) => {
|
||||
const idx = flatIdx++;
|
||||
return (
|
||||
<div key={cmd.id} data-index={idx}>
|
||||
<CommandPaletteItem
|
||||
command={cmd}
|
||||
isActive={idx === activeIndex}
|
||||
onSelect={() => handleSelect(cmd)}
|
||||
onHighlight={() => setActiveIndex(idx)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-4 px-3 py-2 border-t border-surface-800 text-[10px] text-surface-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono">↑↓</kbd>
|
||||
navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono">↵</kbd>
|
||||
select
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono">Esc</kbd>
|
||||
close
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { closePalette(); openHelp(); }}
|
||||
className="ml-auto hover:text-surface-300 transition-colors"
|
||||
>
|
||||
? View all shortcuts
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
89
web/components/command-palette/CommandPaletteItem.tsx
Normal file
89
web/components/command-palette/CommandPaletteItem.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MessageSquarePlus,
|
||||
Trash2,
|
||||
Settings,
|
||||
Sun,
|
||||
Search,
|
||||
HelpCircle,
|
||||
PanelLeftClose,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ShortcutBadge } from "@/components/shortcuts/ShortcutBadge";
|
||||
import type { Command } from "@/lib/shortcuts";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
MessageSquarePlus,
|
||||
Trash2,
|
||||
Settings,
|
||||
Sun,
|
||||
Search,
|
||||
HelpCircle,
|
||||
PanelLeftClose,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
};
|
||||
|
||||
interface CommandPaletteItemProps {
|
||||
command: Command;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onHighlight: () => void;
|
||||
}
|
||||
|
||||
export function CommandPaletteItem({
|
||||
command,
|
||||
isActive,
|
||||
onSelect,
|
||||
onHighlight,
|
||||
}: CommandPaletteItemProps) {
|
||||
const Icon = command.icon ? (ICON_MAP[command.icon] ?? ChevronRight) : ChevronRight;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onHighlight}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 cursor-pointer select-none",
|
||||
"transition-colors",
|
||||
isActive ? "bg-brand-600/20 text-surface-100" : "text-surface-300 hover:bg-surface-800"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center",
|
||||
isActive ? "bg-brand-600/30 text-brand-400" : "bg-surface-800 text-surface-500"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{command.label}</p>
|
||||
{command.description && (
|
||||
<p className="text-xs text-surface-500 truncate">{command.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0.5 rounded border font-medium",
|
||||
isActive
|
||||
? "border-brand-600/40 text-brand-400 bg-brand-600/10"
|
||||
: "border-surface-700 text-surface-600 bg-surface-800"
|
||||
)}
|
||||
>
|
||||
{command.category}
|
||||
</span>
|
||||
{command.keys.length > 0 && <ShortcutBadge keys={command.keys} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
web/components/export/ExportOptions.tsx
Normal file
83
web/components/export/ExportOptions.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import type { ExportOptions, ExportFormat } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OptionRowProps {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function OptionRow({ id, label, description, checked, onCheckedChange, disabled }: OptionRowProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between gap-4 py-2", disabled && "opacity-40")}>
|
||||
<label htmlFor={id} className={cn("flex flex-col gap-0.5", !disabled && "cursor-pointer")}>
|
||||
<span className="text-sm text-surface-200">{label}</span>
|
||||
{description && (
|
||||
<span className="text-xs text-surface-500">{description}</span>
|
||||
)}
|
||||
</label>
|
||||
<Switch.Root
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-9 h-5 rounded-full transition-colors outline-none cursor-pointer",
|
||||
"data-[state=checked]:bg-brand-600 data-[state=unchecked]:bg-surface-700",
|
||||
"disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Switch.Thumb className="block w-4 h-4 bg-white rounded-full shadow transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5" />
|
||||
</Switch.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportOptionsProps {
|
||||
options: ExportOptions;
|
||||
onChange: (opts: Partial<ExportOptions>) => void;
|
||||
}
|
||||
|
||||
export function ExportOptionsPanel({ options, onChange }: ExportOptionsProps) {
|
||||
const isJson = options.format === "json";
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-surface-800">
|
||||
<OptionRow
|
||||
id="opt-tool-use"
|
||||
label="Include tool use"
|
||||
description="Show tool calls and results in the export"
|
||||
checked={options.includeToolUse}
|
||||
onCheckedChange={(v) => onChange({ includeToolUse: v })}
|
||||
/>
|
||||
<OptionRow
|
||||
id="opt-thinking"
|
||||
label="Include thinking blocks"
|
||||
description="Show extended thinking when present"
|
||||
checked={options.includeThinking}
|
||||
onCheckedChange={(v) => onChange({ includeThinking: v })}
|
||||
disabled={isJson}
|
||||
/>
|
||||
<OptionRow
|
||||
id="opt-timestamps"
|
||||
label="Include timestamps"
|
||||
description="Add date/time to messages and metadata"
|
||||
checked={options.includeTimestamps}
|
||||
onCheckedChange={(v) => onChange({ includeTimestamps: v })}
|
||||
/>
|
||||
<OptionRow
|
||||
id="opt-file-contents"
|
||||
label="Include full file contents"
|
||||
description="Show complete tool result output (may be large)"
|
||||
checked={options.includeFileContents}
|
||||
onCheckedChange={(v) => onChange({ includeFileContents: v })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
web/components/export/FormatSelector.tsx
Normal file
73
web/components/export/FormatSelector.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Braces, Globe, FileDown, AlignLeft } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ExportFormat } from "@/lib/types";
|
||||
|
||||
interface FormatOption {
|
||||
value: ExportFormat;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const FORMATS: FormatOption[] = [
|
||||
{
|
||||
value: "markdown",
|
||||
label: "Markdown",
|
||||
description: "Clean .md with code blocks and metadata",
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
value: "json",
|
||||
label: "JSON",
|
||||
description: "Full conversation data with tool use",
|
||||
icon: <Braces className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
value: "html",
|
||||
label: "HTML",
|
||||
description: "Self-contained file with embedded styles",
|
||||
icon: <Globe className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
value: "pdf",
|
||||
label: "PDF",
|
||||
description: "Print-to-PDF via browser dialog",
|
||||
icon: <FileDown className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
value: "plaintext",
|
||||
label: "Plain Text",
|
||||
description: "Stripped of all formatting",
|
||||
icon: <AlignLeft className="w-4 h-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
interface FormatSelectorProps {
|
||||
value: ExportFormat;
|
||||
onChange: (format: ExportFormat) => void;
|
||||
}
|
||||
|
||||
export function FormatSelector({ value, onChange }: FormatSelectorProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt.value}
|
||||
onClick={() => onChange(fmt.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 px-2 py-3 rounded-lg border text-center transition-colors",
|
||||
value === fmt.value
|
||||
? "border-brand-500 bg-brand-500/10 text-brand-300"
|
||||
: "border-surface-700 bg-surface-800 text-surface-400 hover:border-surface-600 hover:text-surface-200"
|
||||
)}
|
||||
title={fmt.description}
|
||||
>
|
||||
{fmt.icon}
|
||||
<span className="text-xs font-medium leading-none">{fmt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
web/components/file-viewer/FileBreadcrumb.tsx
Normal file
82
web/components/file-viewer/FileBreadcrumb.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Check, ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FileBreadcrumbProps {
|
||||
path: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileBreadcrumb({ path, className }: FileBreadcrumbProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const isAbsolute = path.startsWith("/");
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(path);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const openInVSCode = () => {
|
||||
window.open(`vscode://file${isAbsolute ? path : `/${path}`}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-3 py-1.5 border-b border-surface-800 bg-surface-900/80",
|
||||
"text-xs text-surface-400 min-w-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Segments */}
|
||||
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden">
|
||||
{isAbsolute && (
|
||||
<span className="text-surface-600 flex-shrink-0">/</span>
|
||||
)}
|
||||
{segments.map((seg, i) => (
|
||||
<span key={i} className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{i > 0 && <span className="text-surface-700 mx-0.5">/</span>}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate max-w-[120px]",
|
||||
i === segments.length - 1
|
||||
? "text-surface-200 font-medium"
|
||||
: "text-surface-500 hover:text-surface-300 cursor-pointer transition-colors"
|
||||
)}
|
||||
title={seg}
|
||||
>
|
||||
{seg}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded hover:bg-surface-800 transition-colors"
|
||||
title="Copy path"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={openInVSCode}
|
||||
className="p-1 rounded hover:bg-surface-800 transition-colors"
|
||||
title="Open in VS Code"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
web/components/file-viewer/FileInfoBar.tsx
Normal file
98
web/components/file-viewer/FileInfoBar.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Edit3, GitCompare } from "lucide-react";
|
||||
import { useFileViewerStore, type FileTab, type FileViewMode } from "@/lib/fileViewerStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FileInfoBarProps {
|
||||
tab: FileTab;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
typescript: "TypeScript",
|
||||
tsx: "TSX",
|
||||
javascript: "JavaScript",
|
||||
jsx: "JSX",
|
||||
python: "Python",
|
||||
rust: "Rust",
|
||||
go: "Go",
|
||||
css: "CSS",
|
||||
scss: "SCSS",
|
||||
html: "HTML",
|
||||
json: "JSON",
|
||||
markdown: "Markdown",
|
||||
bash: "Bash",
|
||||
yaml: "YAML",
|
||||
toml: "TOML",
|
||||
sql: "SQL",
|
||||
graphql: "GraphQL",
|
||||
ruby: "Ruby",
|
||||
java: "Java",
|
||||
c: "C",
|
||||
cpp: "C++",
|
||||
csharp: "C#",
|
||||
php: "PHP",
|
||||
swift: "Swift",
|
||||
kotlin: "Kotlin",
|
||||
dockerfile: "Dockerfile",
|
||||
makefile: "Makefile",
|
||||
text: "Plain Text",
|
||||
};
|
||||
|
||||
const VIEW_MODES: { mode: FileViewMode; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ mode: "view", label: "View", icon: Eye },
|
||||
{ mode: "edit", label: "Edit", icon: Edit3 },
|
||||
{ mode: "diff", label: "Diff", icon: GitCompare },
|
||||
];
|
||||
|
||||
export function FileInfoBar({ tab }: FileInfoBarProps) {
|
||||
const { setMode } = useFileViewerStore();
|
||||
|
||||
const lineCount = tab.content.split("\n").length;
|
||||
const byteSize = new TextEncoder().encode(tab.content).length;
|
||||
const langLabel = LANGUAGE_LABELS[tab.language] ?? tab.language;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1 border-t border-surface-800 bg-surface-950 text-xs text-surface-500">
|
||||
{/* Left: file stats */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-surface-400">{langLabel}</span>
|
||||
<span>UTF-8</span>
|
||||
<span>{lineCount.toLocaleString()} lines</span>
|
||||
<span>{formatBytes(byteSize)}</span>
|
||||
{tab.isDirty && (
|
||||
<span className="text-yellow-500">● Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: mode switcher */}
|
||||
{!tab.isImage && (
|
||||
<div className="flex items-center gap-0.5 bg-surface-900 rounded px-1 py-0.5">
|
||||
{VIEW_MODES.map(({ mode, label, icon: Icon }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setMode(tab.id, mode)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors",
|
||||
tab.mode === mode
|
||||
? "bg-surface-700 text-surface-100"
|
||||
: "text-surface-500 hover:text-surface-300"
|
||||
)}
|
||||
disabled={mode === "diff" && !tab.diff}
|
||||
title={mode === "diff" && !tab.diff ? "No diff available" : label}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
web/components/file-viewer/ImageViewer.tsx
Normal file
107
web/components/file-viewer/ImageViewer.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { ZoomIn, ZoomOut, Maximize2, Image as ImageIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageViewerProps {
|
||||
src: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function ImageViewer({ src, path }: ImageViewerProps) {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [fitMode, setFitMode] = useState<"fit" | "actual">("fit");
|
||||
const [error, setError] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleZoomIn = () => setZoom((z) => Math.min(z * 1.25, 8));
|
||||
const handleZoomOut = () => setZoom((z) => Math.max(z / 1.25, 0.1));
|
||||
const handleFitToggle = () => {
|
||||
setFitMode((m) => (m === "fit" ? "actual" : "fit"));
|
||||
setZoom(1);
|
||||
};
|
||||
|
||||
const isSvg = path.endsWith(".svg");
|
||||
const hasTransparency = path.endsWith(".png") || path.endsWith(".gif") ||
|
||||
path.endsWith(".webp") || path.endsWith(".svg");
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-surface-500">
|
||||
<ImageIcon className="w-10 h-10" />
|
||||
<p className="text-sm">Failed to load image</p>
|
||||
<p className="text-xs text-surface-600">{path}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-surface-800 bg-surface-900/50">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-xs text-surface-400 w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-surface-800 mx-1" />
|
||||
<button
|
||||
onClick={handleFitToggle}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors",
|
||||
fitMode === "fit"
|
||||
? "text-brand-400 bg-brand-900/30"
|
||||
: "text-surface-500 hover:text-surface-200 hover:bg-surface-800"
|
||||
)}
|
||||
title={fitMode === "fit" ? "Switch to actual size" : "Switch to fit width"}
|
||||
>
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
{fitMode === "fit" ? "Fit" : "Actual"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Image container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-auto flex items-center justify-center p-4"
|
||||
style={{
|
||||
backgroundImage: hasTransparency
|
||||
? "linear-gradient(45deg, #3f3f46 25%, transparent 25%), linear-gradient(-45deg, #3f3f46 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #3f3f46 75%), linear-gradient(-45deg, transparent 75%, #3f3f46 75%)"
|
||||
: undefined,
|
||||
backgroundSize: hasTransparency ? "16px 16px" : undefined,
|
||||
backgroundPosition: hasTransparency ? "0 0, 0 8px, 8px -8px, -8px 0px" : undefined,
|
||||
backgroundColor: hasTransparency ? "#27272a" : "#1a1a1e",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={path.split("/").pop()}
|
||||
onError={() => setError(true)}
|
||||
style={{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: "center center",
|
||||
maxWidth: fitMode === "fit" ? "100%" : "none",
|
||||
maxHeight: fitMode === "fit" ? "100%" : "none",
|
||||
imageRendering: zoom > 2 ? "pixelated" : "auto",
|
||||
}}
|
||||
className="transition-transform duration-150 shadow-lg"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
web/components/file-viewer/SearchBar.tsx
Normal file
250
web/components/file-viewer/SearchBar.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, ChevronUp, ChevronDown, Regex, CaseSensitive } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SearchBarProps {
|
||||
content: string;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SearchBar({ content, containerRef, onClose }: SearchBarProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isRegex, setIsRegex] = useState(false);
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [currentMatch, setCurrentMatch] = useState(0);
|
||||
const [totalMatches, setTotalMatches] = useState(0);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Compute matches
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
setTotalMatches(0);
|
||||
setCurrentMatch(0);
|
||||
clearHighlights();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const flags = caseSensitive ? "g" : "gi";
|
||||
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
|
||||
const matches = Array.from(content.matchAll(pattern));
|
||||
setTotalMatches(matches.length);
|
||||
setCurrentMatch(matches.length > 0 ? 1 : 0);
|
||||
setHasError(false);
|
||||
} catch {
|
||||
setHasError(true);
|
||||
setTotalMatches(0);
|
||||
setCurrentMatch(0);
|
||||
}
|
||||
}, [query, isRegex, caseSensitive, content]);
|
||||
|
||||
// Apply DOM highlights
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
clearHighlights();
|
||||
|
||||
if (!query || hasError || totalMatches === 0) return;
|
||||
|
||||
try {
|
||||
const flags = caseSensitive ? "g" : "gi";
|
||||
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
containerRef.current,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
// Skip nodes inside already-marked elements
|
||||
if ((node.parentElement as HTMLElement)?.tagName === "MARK") {
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const textNodes: Text[] = [];
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
|
||||
let matchIdx = 0;
|
||||
// Process in reverse order to avoid position shifting
|
||||
const replacements: { node: Text; ranges: { start: number; end: number; idx: number }[] }[] = [];
|
||||
|
||||
for (const textNode of textNodes) {
|
||||
const text = textNode.textContent ?? "";
|
||||
pattern.lastIndex = 0;
|
||||
const nodeRanges: { start: number; end: number; idx: number }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pattern.exec(text)) !== null) {
|
||||
nodeRanges.push({ start: m.index, end: m.index + m[0].length, idx: matchIdx++ });
|
||||
if (m[0].length === 0) break; // prevent infinite loop on zero-width matches
|
||||
}
|
||||
if (nodeRanges.length > 0) {
|
||||
replacements.push({ node: textNode, ranges: nodeRanges });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply replacements in document order but process ranges in reverse
|
||||
for (const { node: textNode, ranges } of replacements) {
|
||||
const text = textNode.textContent ?? "";
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastEnd = 0;
|
||||
|
||||
for (const { start, end, idx } of ranges) {
|
||||
if (start > lastEnd) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastEnd, start)));
|
||||
}
|
||||
const mark = document.createElement("mark");
|
||||
mark.className = cn(
|
||||
"search-highlight",
|
||||
idx === currentMatch - 1 ? "search-highlight-current" : ""
|
||||
);
|
||||
mark.textContent = text.slice(start, end);
|
||||
fragment.appendChild(mark);
|
||||
lastEnd = end;
|
||||
}
|
||||
if (lastEnd < text.length) {
|
||||
fragment.appendChild(document.createTextNode(text.slice(lastEnd)));
|
||||
}
|
||||
textNode.parentNode?.replaceChild(fragment, textNode);
|
||||
}
|
||||
|
||||
// Scroll current match into view
|
||||
const currentEl = containerRef.current?.querySelector(".search-highlight-current");
|
||||
currentEl?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
} catch {
|
||||
// Ignore DOM errors
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (containerRef.current) clearHighlights();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query, isRegex, caseSensitive, currentMatch, totalMatches]);
|
||||
|
||||
function clearHighlights() {
|
||||
if (!containerRef.current) return;
|
||||
const marks = containerRef.current.querySelectorAll("mark.search-highlight");
|
||||
marks.forEach((mark) => {
|
||||
mark.replaceWith(mark.textContent ?? "");
|
||||
});
|
||||
// Normalize text nodes
|
||||
containerRef.current.normalize();
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
setCurrentMatch((c) => (c >= totalMatches ? 1 : c + 1));
|
||||
};
|
||||
|
||||
const goPrev = () => {
|
||||
setCurrentMatch((c) => (c <= 1 ? totalMatches : c - 1));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.shiftKey ? goPrev() : goNext();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
clearHighlights();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-surface-800 bg-surface-900/90 backdrop-blur-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 flex-1 bg-surface-800 rounded px-2 py-1",
|
||||
hasError && "ring-1 ring-red-500/50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search..."
|
||||
className="flex-1 bg-transparent text-xs text-surface-100 placeholder-surface-500 outline-none min-w-0"
|
||||
/>
|
||||
|
||||
{/* Match count */}
|
||||
{query && (
|
||||
<span className={cn(
|
||||
"text-xs flex-shrink-0",
|
||||
hasError ? "text-red-400" : totalMatches === 0 ? "text-red-400" : "text-surface-400"
|
||||
)}>
|
||||
{hasError ? "Invalid regex" : totalMatches === 0 ? "No results" : `${currentMatch}/${totalMatches}`}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Toggles */}
|
||||
<button
|
||||
onClick={() => setCaseSensitive((v) => !v)}
|
||||
className={cn(
|
||||
"p-0.5 rounded transition-colors flex-shrink-0",
|
||||
caseSensitive
|
||||
? "text-brand-400 bg-brand-900/40"
|
||||
: "text-surface-500 hover:text-surface-300"
|
||||
)}
|
||||
title="Case sensitive"
|
||||
>
|
||||
<CaseSensitive className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsRegex((v) => !v)}
|
||||
className={cn(
|
||||
"p-0.5 rounded transition-colors flex-shrink-0",
|
||||
isRegex
|
||||
? "text-brand-400 bg-brand-900/40"
|
||||
: "text-surface-500 hover:text-surface-300"
|
||||
)}
|
||||
title="Use regular expression"
|
||||
>
|
||||
<Regex className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<button
|
||||
onClick={goPrev}
|
||||
disabled={totalMatches === 0}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
|
||||
title="Previous match (Shift+Enter)"
|
||||
>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={totalMatches === 0}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
|
||||
title="Next match (Enter)"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { clearHighlights(); onClose(); }}
|
||||
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Close (Escape)"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
64
web/components/layout/Header.tsx
Normal file
64
web/components/layout/Header.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { MODELS } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NotificationCenter } from "@/components/notifications/NotificationCenter";
|
||||
|
||||
export function Header() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { settings, updateSettings } = useChatStore();
|
||||
|
||||
const themeIcons = {
|
||||
light: Sun,
|
||||
dark: Moon,
|
||||
system: Monitor,
|
||||
} as const;
|
||||
|
||||
const ThemeIcon = themeIcons[theme];
|
||||
const nextTheme = theme === "dark" ? "light" : theme === "light" ? "system" : "dark";
|
||||
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 py-2.5 border-b border-surface-800 bg-surface-900/50 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-sm font-medium text-surface-100">Chat</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Model selector */}
|
||||
<label htmlFor="model-select" className="sr-only">
|
||||
Model
|
||||
</label>
|
||||
<select
|
||||
id="model-select"
|
||||
value={settings.model}
|
||||
onChange={(e) => updateSettings({ model: e.target.value })}
|
||||
className={cn(
|
||||
"text-xs bg-surface-800 border border-surface-700 rounded-md px-2 py-1",
|
||||
"text-surface-300 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
)}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Notification center */}
|
||||
<NotificationCenter />
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() => setTheme(nextTheme)}
|
||||
aria-label={`Switch to ${nextTheme} theme`}
|
||||
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
|
||||
>
|
||||
<ThemeIcon className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
189
web/components/layout/Sidebar.tsx
Normal file
189
web/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MessageSquare, FolderOpen, Settings, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatHistory } from "./ChatHistory";
|
||||
import { FileExplorer } from "./FileExplorer";
|
||||
import { QuickActions } from "./QuickActions";
|
||||
|
||||
const MIN_WIDTH = 200;
|
||||
const MAX_WIDTH = 480;
|
||||
const COLLAPSED_WIDTH = 60;
|
||||
|
||||
type SidebarTab = "chats" | "history" | "files" | "settings";
|
||||
|
||||
const TABS: Array<{ id: SidebarTab; icon: React.ElementType; label: string }> = [
|
||||
{ id: "chats", icon: MessageSquare, label: "Chats" },
|
||||
{ id: "files", icon: FolderOpen, label: "Files" },
|
||||
{ id: "settings", icon: Settings, label: "Settings" },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const {
|
||||
sidebarOpen,
|
||||
sidebarWidth,
|
||||
sidebarTab,
|
||||
toggleSidebar,
|
||||
setSidebarWidth,
|
||||
setSidebarTab,
|
||||
openSettings,
|
||||
} = useChatStore();
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
|
||||
const startResize = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
resizeRef.current = { startX: e.clientX, startWidth: sidebarWidth };
|
||||
setIsResizing(true);
|
||||
},
|
||||
[sidebarWidth]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const delta = e.clientX - resizeRef.current.startX;
|
||||
const next = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.startWidth + delta));
|
||||
setSidebarWidth(next);
|
||||
};
|
||||
const onUp = () => setIsResizing(false);
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [isResizing, setSidebarWidth]);
|
||||
|
||||
// Global keyboard shortcut: Cmd/Ctrl+B
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "b") {
|
||||
e.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
const handleTabClick = (id: SidebarTab) => {
|
||||
if (id === "settings") {
|
||||
openSettings();
|
||||
return;
|
||||
}
|
||||
if (!sidebarOpen) toggleSidebar();
|
||||
setSidebarTab(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
className={cn(
|
||||
"hidden md:flex flex-col h-full bg-surface-900 border-r border-surface-800",
|
||||
"relative flex-shrink-0 z-20",
|
||||
isResizing && "select-none"
|
||||
)}
|
||||
animate={{ width: sidebarOpen ? sidebarWidth : COLLAPSED_WIDTH }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
aria-label="Navigation sidebar"
|
||||
>
|
||||
{/* Top bar: app name + tabs + collapse toggle */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex border-b border-surface-800 flex-shrink-0",
|
||||
sidebarOpen ? "flex-row items-center" : "flex-col items-center py-2 gap-1"
|
||||
)}
|
||||
>
|
||||
{sidebarOpen && (
|
||||
<span className="flex-1 text-sm font-semibold text-surface-100 px-4 py-3 truncate">
|
||||
Claude Code
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
sidebarOpen
|
||||
? "flex-row items-center gap-0.5 pr-1 py-1.5"
|
||||
: "flex-col w-full px-1.5 gap-0.5"
|
||||
)}
|
||||
>
|
||||
{TABS.map(({ id, icon: Icon, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => handleTabClick(id)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md text-xs font-medium transition-colors",
|
||||
sidebarOpen ? "px-2.5 py-1.5" : "w-full justify-center px-0 py-2",
|
||||
sidebarOpen && sidebarTab === id && id !== "settings"
|
||||
? "bg-surface-800 text-surface-100"
|
||||
: "text-surface-500 hover:text-surface-300 hover:bg-surface-800/60"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
|
||||
{sidebarOpen && <span>{label}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
title={sidebarOpen ? "Collapse sidebar (⌘B)" : "Expand sidebar (⌘B)"}
|
||||
aria-label={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
|
||||
className={cn(
|
||||
"p-2 rounded-md text-surface-500 hover:text-surface-300 hover:bg-surface-800/60 transition-colors",
|
||||
sidebarOpen ? "mr-1" : "my-0.5"
|
||||
)}
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<AnimatePresence mode="wait">
|
||||
{sidebarOpen && (
|
||||
<motion.div
|
||||
key={sidebarTab}
|
||||
className="flex-1 flex flex-col min-h-0 overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
{(sidebarTab === "chats" || sidebarTab === "history") && <ChatHistory />}
|
||||
{sidebarTab === "files" && <FileExplorer />}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{sidebarOpen && <QuickActions />}
|
||||
|
||||
{/* Drag-to-resize handle */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
onMouseDown={startResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize sidebar"
|
||||
className={cn(
|
||||
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-10 transition-colors",
|
||||
"hover:bg-brand-500/40",
|
||||
isResizing && "bg-brand-500/60"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
29
web/components/layout/SidebarToggle.tsx
Normal file
29
web/components/layout/SidebarToggle.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SidebarToggle({ className }: SidebarToggleProps) {
|
||||
const { sidebarOpen, toggleSidebar } = useChatStore();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
title={sidebarOpen ? "Close sidebar (⌘B)" : "Open sidebar (⌘B)"}
|
||||
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
|
||||
aria-expanded={sidebarOpen}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<PanelLeft className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
57
web/components/layout/ThemeProvider.tsx
Normal file
57
web/components/layout/ThemeProvider.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: "light" | "dark";
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: "dark",
|
||||
setTheme: () => {},
|
||||
resolvedTheme: "dark",
|
||||
});
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { settings, updateSettings } = useChatStore();
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
const resolve = () => {
|
||||
if (settings.theme === "system") {
|
||||
return mediaQuery.matches ? "dark" : "light";
|
||||
}
|
||||
return settings.theme;
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
const resolved = resolve();
|
||||
setResolvedTheme(resolved);
|
||||
// Dark is the default; add `.light` class for light theme
|
||||
document.documentElement.classList.toggle("light", resolved === "light");
|
||||
};
|
||||
|
||||
apply();
|
||||
mediaQuery.addEventListener("change", apply);
|
||||
return () => mediaQuery.removeEventListener("change", apply);
|
||||
}, [settings.theme]);
|
||||
|
||||
const setTheme = (theme: Theme) => {
|
||||
updateSettings({ theme });
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme: settings.theme, setTheme, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => useContext(ThemeContext);
|
||||
100
web/components/mobile/BottomSheet.tsx
Normal file
100
web/components/mobile/BottomSheet.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTouchGesture } from "@/hooks/useTouchGesture";
|
||||
|
||||
interface BottomSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style bottom sheet.
|
||||
* - Slides up from the bottom of the screen
|
||||
* - Swipe down on the drag handle or sheet body to close
|
||||
* - Tap backdrop to close
|
||||
* - Locks body scroll while open
|
||||
*/
|
||||
export function BottomSheet({ isOpen, onClose, title, children, className }: BottomSheetProps) {
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const swipeHandlers = useTouchGesture({ onSwipeDown: onClose });
|
||||
|
||||
// Lock body scroll while open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 bg-black/60",
|
||||
"transition-opacity duration-300",
|
||||
isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title ?? "Options"}
|
||||
className={cn(
|
||||
"fixed bottom-0 left-0 right-0 z-50",
|
||||
"bg-surface-900 border-t border-surface-800 rounded-t-2xl",
|
||||
"max-h-[85dvh] flex flex-col",
|
||||
"transition-transform duration-300 ease-out",
|
||||
isOpen ? "translate-y-0" : "translate-y-full",
|
||||
className
|
||||
)}
|
||||
{...swipeHandlers}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-3 pb-1 flex-shrink-0 cursor-grab active:cursor-grabbing">
|
||||
<div className="w-10 h-1 bg-surface-600 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Optional title */}
|
||||
{title && (
|
||||
<div className="px-4 pb-2 flex-shrink-0">
|
||||
<h2 className="text-sm font-semibold text-surface-100 text-center">{title}</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* iOS safe area bottom padding */}
|
||||
<div className="pb-safe flex-shrink-0" style={{ paddingBottom: "env(safe-area-inset-bottom)" }} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
107
web/components/mobile/MobileFileViewer.tsx
Normal file
107
web/components/mobile/MobileFileViewer.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { X, Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTouchGesture } from "@/hooks/useTouchGesture";
|
||||
|
||||
interface MobileFileViewerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
fileName?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen file viewer overlay for mobile.
|
||||
* - Slides up from the bottom on open
|
||||
* - Swipe down to close
|
||||
* - Back/close button in header
|
||||
* - Content area is scrollable with pinch-to-zoom enabled on images
|
||||
*/
|
||||
export function MobileFileViewer({
|
||||
isOpen,
|
||||
onClose,
|
||||
fileName,
|
||||
children,
|
||||
className,
|
||||
}: MobileFileViewerProps) {
|
||||
const swipeHandlers = useTouchGesture({ onSwipeDown: onClose, threshold: 80 });
|
||||
|
||||
// Lock body scroll and close on Escape
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-surface-950 flex flex-col",
|
||||
"transition-transform duration-300 ease-out",
|
||||
isOpen ? "translate-y-0" : "translate-y-full",
|
||||
className
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={fileName ?? "File viewer"}
|
||||
>
|
||||
{/* Header — swipe-down handle zone */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 border-b border-surface-800 bg-surface-900/80 backdrop-blur-sm h-[52px] flex-shrink-0"
|
||||
{...swipeHandlers}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="absolute left-1/2 -translate-x-1/2 top-2 w-10 h-1 bg-surface-600 rounded-full" />
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
|
||||
aria-label="Close file viewer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<span className="flex-1 text-sm font-medium text-surface-100 truncate">{fileName}</span>
|
||||
|
||||
<button
|
||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
|
||||
aria-label="Download file"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File content — pinch-to-zoom enabled via touch-action */}
|
||||
<div
|
||||
className="flex-1 overflow-auto overscroll-contain"
|
||||
style={{ touchAction: "pan-x pan-y pinch-zoom" }}
|
||||
>
|
||||
{children ?? (
|
||||
<div className="flex items-center justify-center h-full text-surface-500 text-sm">
|
||||
No file selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* iOS safe area inset */}
|
||||
<div style={{ paddingBottom: "env(safe-area-inset-bottom)" }} className="flex-shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
web/components/mobile/MobileHeader.tsx
Normal file
59
web/components/mobile/MobileHeader.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Menu, ChevronLeft } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MobileHeaderProps {
|
||||
title?: string;
|
||||
onMenuOpen: () => void;
|
||||
onBack?: () => void;
|
||||
right?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact top bar for mobile: hamburger (or back) on the left, title in centre, optional actions on the right.
|
||||
* Tap targets are at least 44×44 px per WCAG / Apple HIG guidelines.
|
||||
*/
|
||||
export function MobileHeader({
|
||||
title = "Chat",
|
||||
onMenuOpen,
|
||||
onBack,
|
||||
right,
|
||||
className,
|
||||
}: MobileHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 border-b border-surface-800 bg-surface-900/80 backdrop-blur-sm",
|
||||
"h-[52px] flex-shrink-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Left action — back or hamburger */}
|
||||
{onBack ? (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onMenuOpen}
|
||||
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="flex-1 text-sm font-medium text-surface-100 truncate">{title}</h1>
|
||||
|
||||
{/* Right actions */}
|
||||
{right && <div className="flex items-center">{right}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
26
web/components/mobile/MobileInput.tsx
Normal file
26
web/components/mobile/MobileInput.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { ChatInput } from "@/components/chat/ChatInput";
|
||||
|
||||
interface MobileInputProps {
|
||||
conversationId: string;
|
||||
/** Height of the software keyboard in px — shifts input above it */
|
||||
keyboardHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile-optimised chat input wrapper.
|
||||
* Uses a paddingBottom equal to the keyboard height so the input floats
|
||||
* above the virtual keyboard without relying on position:fixed (which
|
||||
* breaks on iOS Safari when the keyboard is open).
|
||||
*/
|
||||
export function MobileInput({ conversationId, keyboardHeight }: MobileInputProps) {
|
||||
return (
|
||||
<div
|
||||
style={{ paddingBottom: keyboardHeight }}
|
||||
className="transition-[padding] duration-100"
|
||||
>
|
||||
<ChatInput conversationId={conversationId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
web/components/mobile/MobileSidebar.tsx
Normal file
75
web/components/mobile/MobileSidebar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { useTouchGesture } from "@/hooks/useTouchGesture";
|
||||
|
||||
interface MobileSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slide-in drawer sidebar for mobile / tablet.
|
||||
* - Opens from the left as an overlay
|
||||
* - Swipe left or tap backdrop to close
|
||||
* - Traps focus while open and restores on close
|
||||
* - Locks body scroll while open
|
||||
*/
|
||||
export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
|
||||
// Swipe left on the drawer to close
|
||||
const swipeHandlers = useTouchGesture({ onSwipeLeft: onClose });
|
||||
|
||||
// Lock body scroll while drawer is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 bg-black/60 lg:hidden",
|
||||
"transition-opacity duration-300",
|
||||
isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-0 left-0 bottom-0 z-50 w-72 lg:hidden",
|
||||
"transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Navigation"
|
||||
{...swipeHandlers}
|
||||
>
|
||||
<Sidebar onNavigate={onClose} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
152
web/components/mobile/SwipeableRow.tsx
Normal file
152
web/components/mobile/SwipeableRow.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SwipeAction {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SwipeableRowProps {
|
||||
children: React.ReactNode;
|
||||
leftActions?: SwipeAction[];
|
||||
rightActions?: SwipeAction[];
|
||||
className?: string;
|
||||
/** Width of each action button in px (default 72) */
|
||||
actionWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row that reveals swipe actions when the user drags left (right-actions)
|
||||
* or right (left-actions). Used in the sidebar conversation list for
|
||||
* one-swipe delete.
|
||||
*/
|
||||
export function SwipeableRow({
|
||||
children,
|
||||
leftActions = [],
|
||||
rightActions = [],
|
||||
className,
|
||||
actionWidth = 72,
|
||||
}: SwipeableRowProps) {
|
||||
const [translateX, setTranslateX] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const startXRef = useRef<number | null>(null);
|
||||
const currentXRef = useRef(0);
|
||||
|
||||
const maxLeft = leftActions.length * actionWidth;
|
||||
const maxRight = rightActions.length * actionWidth;
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
startXRef.current = e.touches[0].clientX;
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (startXRef.current === null) return;
|
||||
const dx = e.touches[0].clientX - startXRef.current + currentXRef.current;
|
||||
const clamped = Math.max(-maxRight, Math.min(maxLeft, dx));
|
||||
setTranslateX(clamped);
|
||||
},
|
||||
[maxLeft, maxRight]
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
startXRef.current = null;
|
||||
|
||||
// Snap: if dragged > half an action width, show actions; otherwise reset
|
||||
if (translateX < -(actionWidth / 2) && maxRight > 0) {
|
||||
const snapped = -maxRight;
|
||||
setTranslateX(snapped);
|
||||
currentXRef.current = snapped;
|
||||
} else if (translateX > actionWidth / 2 && maxLeft > 0) {
|
||||
const snapped = maxLeft;
|
||||
setTranslateX(snapped);
|
||||
currentXRef.current = snapped;
|
||||
} else {
|
||||
setTranslateX(0);
|
||||
currentXRef.current = 0;
|
||||
}
|
||||
}, [translateX, actionWidth, maxLeft, maxRight]);
|
||||
|
||||
const resetPosition = useCallback(() => {
|
||||
setTranslateX(0);
|
||||
currentXRef.current = 0;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("relative overflow-hidden", className)}>
|
||||
{/* Left action buttons (revealed on swipe-right) */}
|
||||
{leftActions.length > 0 && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 flex"
|
||||
style={{ width: maxLeft }}
|
||||
>
|
||||
{leftActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
resetPosition();
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 text-xs font-medium min-w-[44px]",
|
||||
"bg-brand-600 text-white",
|
||||
action.className
|
||||
)}
|
||||
style={{ width: actionWidth }}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right action buttons (revealed on swipe-left) */}
|
||||
{rightActions.length > 0 && (
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 flex"
|
||||
style={{ width: maxRight }}
|
||||
>
|
||||
{rightActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
resetPosition();
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-1 text-xs font-medium min-w-[44px]",
|
||||
"bg-red-600 text-white",
|
||||
action.className
|
||||
)}
|
||||
style={{ width: actionWidth }}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content row */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 bg-surface-900",
|
||||
!isDragging && "transition-transform duration-200"
|
||||
)}
|
||||
style={{ transform: `translateX(${translateX}px)` }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
web/components/notifications/NotificationBadge.tsx
Normal file
25
web/components/notifications/NotificationBadge.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NotificationBadgeProps {
|
||||
count: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NotificationBadge({ count, className }: NotificationBadgeProps) {
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-1 -right-1 flex items-center justify-center",
|
||||
"min-w-[16px] h-4 px-1 rounded-full",
|
||||
"bg-brand-500 text-white text-[10px] font-bold leading-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{count > 99 ? "99+" : count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
185
web/components/notifications/NotificationCenter.tsx
Normal file
185
web/components/notifications/NotificationCenter.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Bell, CheckCheck, Trash2 } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNotifications } from "@/hooks/useNotifications";
|
||||
import { NotificationBadge } from "./NotificationBadge";
|
||||
import { NotificationItem } from "./NotificationItem";
|
||||
import type { NotificationCategory } from "@/lib/notifications";
|
||||
|
||||
type FilterCategory = "all" | NotificationCategory;
|
||||
|
||||
const FILTER_TABS: { key: FilterCategory; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "error", label: "Errors" },
|
||||
{ key: "activity", label: "Activity" },
|
||||
{ key: "system", label: "System" },
|
||||
];
|
||||
|
||||
export function NotificationCenter() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeFilter, setActiveFilter] = useState<FilterCategory>("all");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { notifications, unreadCount, markRead, markAllRead, clearHistory } =
|
||||
useNotifications();
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [isOpen]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [isOpen]);
|
||||
|
||||
const filtered =
|
||||
activeFilter === "all"
|
||||
? notifications
|
||||
: notifications.filter((n) => n.category === activeFilter);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* Bell button */}
|
||||
<button
|
||||
onClick={() => setIsOpen((o) => !o)}
|
||||
className={cn(
|
||||
"relative p-1.5 rounded-md transition-colors",
|
||||
"text-surface-400 hover:text-surface-100 hover:bg-surface-800",
|
||||
isOpen && "bg-surface-800 text-surface-100"
|
||||
)}
|
||||
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
<NotificationBadge count={unreadCount} />
|
||||
</button>
|
||||
|
||||
{/* Panel */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.96 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className={cn(
|
||||
"absolute right-0 top-full mt-2 z-50",
|
||||
"w-80 rounded-lg border border-surface-700 shadow-2xl",
|
||||
"bg-surface-900 overflow-hidden",
|
||||
"flex flex-col"
|
||||
)}
|
||||
style={{ maxHeight: "480px" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-800">
|
||||
<h3 className="text-sm font-semibold text-surface-100">Notifications</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="p-1.5 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Mark all as read"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={clearHistory}
|
||||
className="p-1.5 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
|
||||
title="Clear all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex border-b border-surface-800 px-4">
|
||||
{FILTER_TABS.map((tab) => {
|
||||
const count =
|
||||
tab.key === "all"
|
||||
? notifications.length
|
||||
: notifications.filter((n) => n.category === tab.key).length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveFilter(tab.key)}
|
||||
className={cn(
|
||||
"relative px-2 py-2.5 text-xs font-medium transition-colors mr-1",
|
||||
activeFilter === tab.key
|
||||
? "text-surface-100"
|
||||
: "text-surface-500 hover:text-surface-300"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-1 text-[10px] px-1 py-0.5 rounded-full",
|
||||
activeFilter === tab.key
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-surface-700 text-surface-400"
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{activeFilter === tab.key && (
|
||||
<motion.div
|
||||
layoutId="notification-tab-indicator"
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand-500"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Notification list */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Bell className="w-8 h-8 text-surface-700" />
|
||||
<p className="text-sm text-surface-500">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-surface-800/60">
|
||||
{filtered.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
notification={n}
|
||||
onMarkRead={markRead}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
web/components/notifications/NotificationItem.tsx
Normal file
99
web/components/notifications/NotificationItem.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { XCircle, Zap, Settings, ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import type { NotificationItem as NotificationItemType } from "@/lib/notifications";
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: NotificationItemType;
|
||||
onMarkRead: (id: string) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_CONFIG = {
|
||||
error: {
|
||||
icon: XCircle,
|
||||
iconColor: "text-red-400",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
activity: {
|
||||
icon: Zap,
|
||||
iconColor: "text-brand-400",
|
||||
bgColor: "bg-brand-500/10",
|
||||
},
|
||||
system: {
|
||||
icon: Settings,
|
||||
iconColor: "text-surface-400",
|
||||
bgColor: "bg-surface-700/40",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
|
||||
const config = CATEGORY_CONFIG[notification.category];
|
||||
const Icon = config.icon;
|
||||
|
||||
const handleClick = () => {
|
||||
if (!notification.read) {
|
||||
onMarkRead(notification.id);
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 px-4 py-3 transition-colors cursor-pointer",
|
||||
"hover:bg-surface-800/60",
|
||||
!notification.read && "bg-surface-800/30"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 shrink-0 w-7 h-7 rounded-full flex items-center justify-center",
|
||||
config.bgColor
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("w-3.5 h-3.5", config.iconColor)} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm leading-snug",
|
||||
notification.read ? "text-surface-300" : "text-surface-100 font-medium"
|
||||
)}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{!notification.read && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-500 mt-1" />
|
||||
)}
|
||||
{notification.link && (
|
||||
<ExternalLink className="w-3 h-3 text-surface-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-surface-500 mt-0.5 leading-relaxed line-clamp-2">
|
||||
{notification.description}
|
||||
</p>
|
||||
<p className="text-xs text-surface-600 mt-1">
|
||||
{formatDate(notification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (notification.link) {
|
||||
return (
|
||||
<a href={notification.link} className="block no-underline">
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
182
web/components/notifications/Toast.tsx
Normal file
182
web/components/notifications/Toast.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
X,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToastItem } from "@/lib/notifications";
|
||||
|
||||
interface ToastProps {
|
||||
toast: ToastItem;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const VARIANT_CONFIG = {
|
||||
success: {
|
||||
border: "border-green-800",
|
||||
bg: "bg-green-950/90",
|
||||
icon: CheckCircle2,
|
||||
iconColor: "text-green-400",
|
||||
progress: "bg-green-500",
|
||||
},
|
||||
error: {
|
||||
border: "border-red-800",
|
||||
bg: "bg-red-950/90",
|
||||
icon: XCircle,
|
||||
iconColor: "text-red-400",
|
||||
progress: "bg-red-500",
|
||||
},
|
||||
warning: {
|
||||
border: "border-yellow-800",
|
||||
bg: "bg-yellow-950/90",
|
||||
icon: AlertTriangle,
|
||||
iconColor: "text-yellow-400",
|
||||
progress: "bg-yellow-500",
|
||||
},
|
||||
info: {
|
||||
border: "border-blue-800",
|
||||
bg: "bg-blue-950/90",
|
||||
icon: Info,
|
||||
iconColor: "text-blue-400",
|
||||
progress: "bg-blue-500",
|
||||
},
|
||||
loading: {
|
||||
border: "border-surface-700",
|
||||
bg: "bg-surface-800",
|
||||
icon: Loader2,
|
||||
iconColor: "text-brand-400",
|
||||
progress: "bg-brand-500",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function Toast({ toast, onDismiss }: ToastProps) {
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [progress, setProgress] = useState(100);
|
||||
|
||||
// Track remaining time across pause/resume cycles
|
||||
const remainingRef = useRef(toast.duration);
|
||||
|
||||
const dismiss = useCallback(() => onDismiss(toast.id), [onDismiss, toast.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (toast.duration === 0) return; // loading: never auto-dismiss
|
||||
if (paused) return;
|
||||
|
||||
const snapRemaining = remainingRef.current;
|
||||
const start = Date.now();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - start;
|
||||
const newRemaining = Math.max(0, snapRemaining - elapsed);
|
||||
remainingRef.current = newRemaining;
|
||||
setProgress((newRemaining / toast.duration) * 100);
|
||||
|
||||
if (newRemaining === 0) {
|
||||
clearInterval(interval);
|
||||
dismiss();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, toast.duration, dismiss]);
|
||||
|
||||
const config = VARIANT_CONFIG[toast.variant];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-lg shadow-xl border overflow-hidden",
|
||||
"w-80 pointer-events-auto backdrop-blur-sm",
|
||||
config.border,
|
||||
config.bg
|
||||
)}
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-3.5 pb-5">
|
||||
{/* Icon */}
|
||||
<div className={cn("mt-0.5 shrink-0", config.iconColor)}>
|
||||
<Icon
|
||||
className={cn("w-4 h-4", toast.variant === "loading" && "animate-spin")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-surface-100 leading-snug">
|
||||
{toast.title}
|
||||
</p>
|
||||
{toast.description && (
|
||||
<p className="text-xs text-surface-400 mt-0.5 leading-relaxed">
|
||||
{toast.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
toast.action!.onClick();
|
||||
dismiss();
|
||||
}}
|
||||
className="mt-2 text-xs font-medium text-brand-400 hover:text-brand-300 transition-colors"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Expandable details */}
|
||||
{toast.details && (
|
||||
<div className="mt-1.5">
|
||||
<button
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex items-center gap-1 text-xs text-surface-500 hover:text-surface-300 transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"w-3 h-3 transition-transform duration-150",
|
||||
expanded && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
{expanded ? "Hide details" : "Show details"}
|
||||
</button>
|
||||
{expanded && (
|
||||
<pre className="mt-1.5 text-xs text-surface-400 bg-surface-900/80 rounded p-2 overflow-auto max-h-24 font-mono whitespace-pre-wrap break-all">
|
||||
{toast.details}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="shrink-0 text-surface-600 hover:text-surface-200 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{toast.duration > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-surface-700/50">
|
||||
<div
|
||||
className={cn("h-full transition-none", config.progress)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
web/components/notifications/ToastProvider.tsx
Normal file
12
web/components/notifications/ToastProvider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ToastStack } from "./ToastStack";
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<ToastStack />
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
web/components/notifications/ToastStack.tsx
Normal file
33
web/components/notifications/ToastStack.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Toast } from "./Toast";
|
||||
import { useNotificationStore } from "@/lib/notifications";
|
||||
|
||||
export function ToastStack() {
|
||||
const toasts = useNotificationStore((s) => s.toasts);
|
||||
const dismissToast = useNotificationStore((s) => s.dismissToast);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-label="Notifications"
|
||||
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{toasts.map((toast) => (
|
||||
<motion.div
|
||||
key={toast.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 80, scale: 0.92 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 80, scale: 0.92, transition: { duration: 0.15 } }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 28 }}
|
||||
>
|
||||
<Toast toast={toast} onDismiss={dismissToast} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
web/components/settings/ApiSettings.tsx
Normal file
146
web/components/settings/ApiSettings.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff, CheckCircle, XCircle, Loader2 } from "lucide-react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { SettingRow, SectionHeader, Toggle } from "./SettingRow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConnectionStatus = "idle" | "checking" | "ok" | "error";
|
||||
|
||||
export function ApiSettings() {
|
||||
const { settings, updateSettings, resetSettings } = useChatStore();
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("idle");
|
||||
const [latencyMs, setLatencyMs] = useState<number | null>(null);
|
||||
|
||||
async function checkConnection() {
|
||||
setConnectionStatus("checking");
|
||||
setLatencyMs(null);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${settings.apiUrl}/health`, { signal: AbortSignal.timeout(5000) });
|
||||
const ms = Date.now() - start;
|
||||
setLatencyMs(ms);
|
||||
setConnectionStatus(res.ok ? "ok" : "error");
|
||||
} catch {
|
||||
setConnectionStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
const statusIcon = {
|
||||
idle: null,
|
||||
checking: <Loader2 className="w-4 h-4 animate-spin text-surface-400" />,
|
||||
ok: <CheckCircle className="w-4 h-4 text-green-400" />,
|
||||
error: <XCircle className="w-4 h-4 text-red-400" />,
|
||||
}[connectionStatus];
|
||||
|
||||
const statusText = {
|
||||
idle: "Not checked",
|
||||
checking: "Checking...",
|
||||
ok: latencyMs !== null ? `Connected — ${latencyMs}ms` : "Connected",
|
||||
error: "Connection failed",
|
||||
}[connectionStatus];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="API & Authentication" onReset={() => resetSettings("api")} />
|
||||
|
||||
<SettingRow
|
||||
label="API key"
|
||||
description="Your Anthropic API key. Stored locally and never sent to third parties."
|
||||
stack
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={settings.apiKey}
|
||||
onChange={(e) => updateSettings({ apiKey: e.target.value })}
|
||||
placeholder="sk-ant-..."
|
||||
className={cn(
|
||||
"w-full bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 pr-10 text-sm",
|
||||
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-surface-500 hover:text-surface-300 transition-colors"
|
||||
title={showKey ? "Hide key" : "Show key"}
|
||||
>
|
||||
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{settings.apiKey && (
|
||||
<p className="text-xs text-surface-500 mt-1">
|
||||
Key ending in{" "}
|
||||
<span className="font-mono text-surface-400">
|
||||
...{settings.apiKey.slice(-4)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="API base URL"
|
||||
description="Custom endpoint for enterprise or proxy setups. Leave as default for direct Anthropic access."
|
||||
stack
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.apiUrl}
|
||||
onChange={(e) => updateSettings({ apiUrl: e.target.value })}
|
||||
placeholder="http://localhost:3001"
|
||||
className={cn(
|
||||
"w-full bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 text-sm",
|
||||
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Connection status"
|
||||
description="Verify that the API endpoint is reachable."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{statusIcon}
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
connectionStatus === "ok" && "text-green-400",
|
||||
connectionStatus === "error" && "text-red-400",
|
||||
connectionStatus === "idle" && "text-surface-500",
|
||||
connectionStatus === "checking" && "text-surface-400"
|
||||
)}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={checkConnection}
|
||||
disabled={connectionStatus === "checking"}
|
||||
className={cn(
|
||||
"px-3 py-1 text-xs rounded-md border border-surface-700 transition-colors",
|
||||
"text-surface-300 hover:text-surface-100 hover:bg-surface-800",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
Check
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Streaming"
|
||||
description="Stream responses token by token as they are generated."
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.streamingEnabled}
|
||||
onChange={(v) => updateSettings({ streamingEnabled: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
web/components/settings/DataSettings.tsx
Normal file
148
web/components/settings/DataSettings.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Download, Trash2, AlertTriangle } from "lucide-react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { SettingRow, SectionHeader, Toggle } from "./SettingRow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DataSettings() {
|
||||
const { settings, updateSettings, conversations, deleteConversation } = useChatStore();
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
|
||||
function exportConversations(format: "json" | "markdown") {
|
||||
let content: string;
|
||||
let filename: string;
|
||||
const ts = new Date().toISOString().split("T")[0];
|
||||
|
||||
if (format === "json") {
|
||||
content = JSON.stringify(conversations, null, 2);
|
||||
filename = `claude-code-conversations-${ts}.json`;
|
||||
} else {
|
||||
content = conversations
|
||||
.map((conv) => {
|
||||
const messages = conv.messages
|
||||
.map((m) => {
|
||||
const role = m.role === "user" ? "**You**" : "**Claude**";
|
||||
const text = typeof m.content === "string"
|
||||
? m.content
|
||||
: m.content
|
||||
.filter((b) => b.type === "text")
|
||||
.map((b) => (b as { type: "text"; text: string }).text)
|
||||
.join("\n");
|
||||
return `${role}\n\n${text}`;
|
||||
})
|
||||
.join("\n\n---\n\n");
|
||||
return `# ${conv.title}\n\n${messages}`;
|
||||
})
|
||||
.join("\n\n====\n\n");
|
||||
filename = `claude-code-conversations-${ts}.md`;
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function clearAllConversations() {
|
||||
const ids = conversations.map((c) => c.id);
|
||||
ids.forEach((id) => deleteConversation(id));
|
||||
setShowClearConfirm(false);
|
||||
}
|
||||
|
||||
const totalMessages = conversations.reduce((sum, c) => sum + c.messages.length, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="Data & Privacy" />
|
||||
|
||||
<SettingRow
|
||||
label="Export conversations"
|
||||
description={`Export all ${conversations.length} conversations (${totalMessages} messages).`}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => exportConversations("json")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border",
|
||||
"border-surface-700 text-surface-300 hover:text-surface-100 hover:bg-surface-800 transition-colors"
|
||||
)}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportConversations("markdown")}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border",
|
||||
"border-surface-700 text-surface-300 hover:text-surface-100 hover:bg-surface-800 transition-colors"
|
||||
)}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Markdown
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Clear conversation history"
|
||||
description="Permanently delete all conversations. This cannot be undone."
|
||||
>
|
||||
{showClearConfirm ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-red-400 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
Are you sure?
|
||||
</span>
|
||||
<button
|
||||
onClick={clearAllConversations}
|
||||
className="px-3 py-1.5 text-xs rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Delete all
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
className="px-3 py-1.5 text-xs text-surface-400 hover:text-surface-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
disabled={conversations.length === 0}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border",
|
||||
"border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Anonymous telemetry"
|
||||
description="Help improve Claude Code by sharing anonymous usage data. No conversation content is ever sent."
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.telemetryEnabled}
|
||||
onChange={(v) => updateSettings({ telemetryEnabled: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-surface-800">
|
||||
<p className="text-xs text-surface-500">
|
||||
All data is stored locally in your browser. Claude Code does not send conversation data
|
||||
to any server unless explicitly configured.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
web/components/settings/GeneralSettings.tsx
Normal file
114
web/components/settings/GeneralSettings.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { useTheme } from "@/components/layout/ThemeProvider";
|
||||
import { SettingRow, SectionHeader, Toggle, Slider } from "./SettingRow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function GeneralSettings() {
|
||||
const { settings, updateSettings, resetSettings } = useChatStore();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const themes = [
|
||||
{ id: "light" as const, label: "Light", icon: Sun },
|
||||
{ id: "dark" as const, label: "Dark", icon: Moon },
|
||||
{ id: "system" as const, label: "System", icon: Monitor },
|
||||
];
|
||||
|
||||
function handleThemeChange(t: "light" | "dark" | "system") {
|
||||
updateSettings({ theme: t });
|
||||
setTheme(t);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="General" onReset={() => resetSettings("general")} />
|
||||
|
||||
<SettingRow
|
||||
label="Theme"
|
||||
description="Choose the color scheme for the interface."
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
{themes.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => handleThemeChange(id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
|
||||
settings.theme === id
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-surface-800 text-surface-400 hover:text-surface-200"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Chat font size"
|
||||
description="Font size for messages in the chat window."
|
||||
stack
|
||||
>
|
||||
<Slider
|
||||
value={settings.fontSize.chat}
|
||||
min={12}
|
||||
max={20}
|
||||
onChange={(v) =>
|
||||
updateSettings({ fontSize: { ...settings.fontSize, chat: v } })
|
||||
}
|
||||
unit="px"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Code font size"
|
||||
description="Font size for code blocks and inline code."
|
||||
stack
|
||||
>
|
||||
<Slider
|
||||
value={settings.fontSize.code}
|
||||
min={10}
|
||||
max={18}
|
||||
onChange={(v) =>
|
||||
updateSettings({ fontSize: { ...settings.fontSize, code: v } })
|
||||
}
|
||||
unit="px"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Send on Enter"
|
||||
description="Press Enter to send messages. When off, use Cmd+Enter or Ctrl+Enter."
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.sendOnEnter}
|
||||
onChange={(v) => updateSettings({ sendOnEnter: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Show timestamps"
|
||||
description="Display the time each message was sent."
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.showTimestamps}
|
||||
onChange={(v) => updateSettings({ showTimestamps: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Compact mode"
|
||||
description="Reduce spacing between messages for higher information density."
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.compactMode}
|
||||
onChange={(v) => updateSettings({ compactMode: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
web/components/settings/KeyboardSettings.tsx
Normal file
161
web/components/settings/KeyboardSettings.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { SectionHeader } from "./SettingRow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_SHORTCUTS: Record<string, string> = {
|
||||
"new-conversation": "Ctrl+Shift+N",
|
||||
"send-message": "Enter",
|
||||
"focus-input": "Ctrl+L",
|
||||
"toggle-sidebar": "Ctrl+B",
|
||||
"open-settings": "Ctrl+,",
|
||||
"command-palette": "Ctrl+K",
|
||||
};
|
||||
|
||||
const SHORTCUT_LABELS: Record<string, { label: string; description: string }> = {
|
||||
"new-conversation": { label: "New conversation", description: "Start a fresh conversation" },
|
||||
"send-message": { label: "Send message", description: "Submit the current message" },
|
||||
"focus-input": { label: "Focus input", description: "Jump to the message input" },
|
||||
"toggle-sidebar": { label: "Toggle sidebar", description: "Show or hide the sidebar" },
|
||||
"open-settings": { label: "Open settings", description: "Open this settings panel" },
|
||||
"command-palette": { label: "Command palette", description: "Open the command palette" },
|
||||
};
|
||||
|
||||
function captureKeyCombo(e: KeyboardEvent): string {
|
||||
e.preventDefault();
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
|
||||
if (e.altKey) parts.push("Alt");
|
||||
if (e.shiftKey) parts.push("Shift");
|
||||
if (e.key && !["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
|
||||
parts.push(e.key === " " ? "Space" : e.key);
|
||||
}
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
function ShortcutRow({
|
||||
id,
|
||||
binding,
|
||||
isDefault,
|
||||
isConflict,
|
||||
onRebind,
|
||||
onReset,
|
||||
}: {
|
||||
id: string;
|
||||
binding: string;
|
||||
isDefault: boolean;
|
||||
isConflict: boolean;
|
||||
onRebind: (combo: string) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const [listening, setListening] = useState(false);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const info = SHORTCUT_LABELS[id];
|
||||
|
||||
useEffect(() => {
|
||||
if (!listening) return;
|
||||
function handler(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setListening(false);
|
||||
return;
|
||||
}
|
||||
const combo = captureKeyCombo(e);
|
||||
if (combo) {
|
||||
onRebind(combo);
|
||||
setListening(false);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [listening, onRebind]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between py-3 border-b border-surface-800 last:border-0",
|
||||
isConflict && "bg-red-500/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-surface-200">{info?.label ?? id}</p>
|
||||
<p className="text-xs text-surface-500">{info?.description}</p>
|
||||
{isConflict && (
|
||||
<p className="text-xs text-red-400 mt-0.5">Conflict with another shortcut</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={() => setListening(true)}
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-md text-xs font-mono transition-colors border",
|
||||
listening
|
||||
? "bg-brand-600/20 border-brand-500 text-brand-300 animate-pulse"
|
||||
: isConflict
|
||||
? "bg-red-500/10 border-red-500/30 text-red-300"
|
||||
: "bg-surface-800 border-surface-700 text-surface-300 hover:border-surface-600"
|
||||
)}
|
||||
>
|
||||
{listening ? "Press keys..." : binding}
|
||||
</button>
|
||||
{!isDefault && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
title="Reset to default"
|
||||
className="text-surface-500 hover:text-surface-300 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyboardSettings() {
|
||||
const { settings, updateSettings, resetSettings } = useChatStore();
|
||||
const keybindings = settings.keybindings;
|
||||
|
||||
// Find conflicts
|
||||
const bindingValues = Object.values(keybindings);
|
||||
const conflicts = new Set(
|
||||
bindingValues.filter((v, i) => bindingValues.indexOf(v) !== i)
|
||||
);
|
||||
|
||||
function rebind(id: string, combo: string) {
|
||||
updateSettings({ keybindings: { ...keybindings, [id]: combo } });
|
||||
}
|
||||
|
||||
function resetOne(id: string) {
|
||||
updateSettings({
|
||||
keybindings: { ...keybindings, [id]: DEFAULT_SHORTCUTS[id] },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="Keyboard Shortcuts" onReset={() => resetSettings("keybindings")} />
|
||||
|
||||
<p className="text-xs text-surface-400 mb-4">
|
||||
Click a shortcut to rebind it. Press Escape to cancel.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
{Object.entries(keybindings).map(([id, binding]) => (
|
||||
<ShortcutRow
|
||||
key={id}
|
||||
id={id}
|
||||
binding={binding}
|
||||
isDefault={binding === DEFAULT_SHORTCUTS[id]}
|
||||
isConflict={conflicts.has(binding)}
|
||||
onRebind={(combo) => rebind(id, combo)}
|
||||
onReset={() => resetOne(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
web/components/settings/McpSettings.tsx
Normal file
256
web/components/settings/McpSettings.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Circle,
|
||||
} from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import type { MCPServerConfig } from "@/lib/types";
|
||||
import { SectionHeader, Toggle } from "./SettingRow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TestStatus = "idle" | "testing" | "ok" | "error";
|
||||
|
||||
function ServerRow({
|
||||
server,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
server: MCPServerConfig;
|
||||
onUpdate: (updated: MCPServerConfig) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<TestStatus>("idle");
|
||||
|
||||
async function testConnection() {
|
||||
setTestStatus("testing");
|
||||
// Simulate connection test — in real impl this would call an API
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
setTestStatus(Math.random() > 0.3 ? "ok" : "error");
|
||||
}
|
||||
|
||||
const statusDot = {
|
||||
idle: <Circle className="w-2 h-2 text-surface-600" />,
|
||||
testing: <Loader2 className="w-3 h-3 animate-spin text-surface-400" />,
|
||||
ok: <CheckCircle className="w-3 h-3 text-green-400" />,
|
||||
error: <XCircle className="w-3 h-3 text-red-400" />,
|
||||
}[testStatus];
|
||||
|
||||
return (
|
||||
<div className="border border-surface-800 rounded-lg overflow-hidden">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 bg-surface-800/40">
|
||||
<Toggle
|
||||
checked={server.enabled}
|
||||
onChange={(v) => onUpdate({ ...server, enabled: v })}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-surface-200 truncate">{server.name}</p>
|
||||
<p className="text-xs text-surface-500 font-mono truncate">{server.command}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusDot}
|
||||
<button
|
||||
onClick={testConnection}
|
||||
disabled={testStatus === "testing"}
|
||||
className="text-xs text-surface-400 hover:text-surface-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-surface-500 hover:text-surface-300 transition-colors"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn("w-4 h-4 transition-transform", expanded && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-surface-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded edit form */}
|
||||
{expanded && (
|
||||
<div className="px-3 py-3 space-y-2 border-t border-surface-800">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-surface-400 mb-1">Name</label>
|
||||
<input
|
||||
value={server.name}
|
||||
onChange={(e) => onUpdate({ ...server, name: e.target.value })}
|
||||
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-surface-400 mb-1">Command</label>
|
||||
<input
|
||||
value={server.command}
|
||||
onChange={(e) => onUpdate({ ...server, command: e.target.value })}
|
||||
placeholder="npx, node, python..."
|
||||
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-surface-400 mb-1">
|
||||
Arguments (space-separated)
|
||||
</label>
|
||||
<input
|
||||
value={server.args.join(" ")}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
...server,
|
||||
args: e.target.value.split(" ").filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder="-y @modelcontextprotocol/server-filesystem /path"
|
||||
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function McpSettings() {
|
||||
const { settings, updateSettings } = useChatStore();
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newServer, setNewServer] = useState<Omit<MCPServerConfig, "id">>({
|
||||
name: "",
|
||||
command: "",
|
||||
args: [],
|
||||
env: {},
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
function updateServer(id: string, updated: MCPServerConfig) {
|
||||
updateSettings({
|
||||
mcpServers: settings.mcpServers.map((s) => (s.id === id ? updated : s)),
|
||||
});
|
||||
}
|
||||
|
||||
function deleteServer(id: string) {
|
||||
updateSettings({
|
||||
mcpServers: settings.mcpServers.filter((s) => s.id !== id),
|
||||
});
|
||||
}
|
||||
|
||||
function addServer() {
|
||||
if (!newServer.name.trim() || !newServer.command.trim()) return;
|
||||
updateSettings({
|
||||
mcpServers: [...settings.mcpServers, { ...newServer, id: nanoid() }],
|
||||
});
|
||||
setNewServer({ name: "", command: "", args: [], env: {}, enabled: true });
|
||||
setShowAddForm(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="MCP Servers" />
|
||||
|
||||
<p className="text-xs text-surface-400 mb-4">
|
||||
Model Context Protocol servers extend Claude with external tools and data sources.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{settings.mcpServers.length === 0 ? (
|
||||
<div className="text-center py-8 text-surface-500 text-sm">
|
||||
No MCP servers configured
|
||||
</div>
|
||||
) : (
|
||||
settings.mcpServers.map((server) => (
|
||||
<ServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
onUpdate={(updated) => updateServer(server.id, updated)}
|
||||
onDelete={() => deleteServer(server.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddForm ? (
|
||||
<div className="border border-surface-700 rounded-lg p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-surface-300 mb-2">Add MCP server</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-surface-400 mb-1">Name *</label>
|
||||
<input
|
||||
value={newServer.name}
|
||||
onChange={(e) => setNewServer((s) => ({ ...s, name: e.target.value }))}
|
||||
placeholder="filesystem"
|
||||
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-surface-400 mb-1">Command *</label>
|
||||
<input
|
||||
value={newServer.command}
|
||||
onChange={(e) => setNewServer((s) => ({ ...s, command: e.target.value }))}
|
||||
placeholder="npx"
|
||||
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-surface-400 mb-1">
|
||||
Arguments (space-separated)
|
||||
</label>
|
||||
<input
|
||||
value={newServer.args.join(" ")}
|
||||
onChange={(e) =>
|
||||
setNewServer((s) => ({
|
||||
...s,
|
||||
args: e.target.value.split(" ").filter(Boolean),
|
||||
}))
|
||||
}
|
||||
placeholder="-y @modelcontextprotocol/server-filesystem /path"
|
||||
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="px-3 py-1.5 text-xs text-surface-400 hover:text-surface-200 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={addServer}
|
||||
disabled={!newServer.name.trim() || !newServer.command.trim()}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs rounded-md bg-brand-600 text-white",
|
||||
"hover:bg-brand-700 transition-colors",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
Add server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-2 text-sm text-surface-400 hover:text-surface-200 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add server
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
web/components/settings/ModelSettings.tsx
Normal file
123
web/components/settings/ModelSettings.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { MODELS } from "@/lib/constants";
|
||||
import { SettingRow, SectionHeader, Slider } from "./SettingRow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ModelSettings() {
|
||||
const { settings, updateSettings, resetSettings } = useChatStore();
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const selectedModel = MODELS.find((m) => m.id === settings.model);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="Model" onReset={() => resetSettings("model")} />
|
||||
|
||||
<SettingRow
|
||||
label="Default model"
|
||||
description="The AI model used for new conversations."
|
||||
>
|
||||
<select
|
||||
value={settings.model}
|
||||
onChange={(e) => updateSettings({ model: e.target.value })}
|
||||
className={cn(
|
||||
"bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 text-sm",
|
||||
"text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
)}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label} — {m.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
{selectedModel && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-surface-800/50 border border-surface-800 text-xs text-surface-400">
|
||||
<span className="font-medium text-surface-300">{selectedModel.label}</span>
|
||||
{" — "}{selectedModel.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
label="Max tokens"
|
||||
description="Maximum number of tokens in the model's response."
|
||||
stack
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={settings.maxTokens}
|
||||
min={1000}
|
||||
max={200000}
|
||||
step={1000}
|
||||
onChange={(v) => updateSettings({ maxTokens: v })}
|
||||
showValue={false}
|
||||
className="flex-1"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.maxTokens}
|
||||
min={1000}
|
||||
max={200000}
|
||||
step={1000}
|
||||
onChange={(e) => updateSettings({ maxTokens: Number(e.target.value) })}
|
||||
className={cn(
|
||||
"w-24 bg-surface-800 border border-surface-700 rounded-md px-2 py-1 text-sm text-right",
|
||||
"text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="System prompt"
|
||||
description="Custom instructions prepended to every conversation."
|
||||
stack
|
||||
>
|
||||
<textarea
|
||||
value={settings.systemPrompt}
|
||||
onChange={(e) => updateSettings({ systemPrompt: e.target.value })}
|
||||
placeholder="You are a helpful assistant..."
|
||||
rows={4}
|
||||
className={cn(
|
||||
"w-full bg-surface-800 border border-surface-700 rounded-md px-3 py-2 text-sm",
|
||||
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500",
|
||||
"resize-none font-mono"
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Advanced toggle */}
|
||||
<button
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-xs text-surface-400 hover:text-surface-200 transition-colors mt-2 mb-1"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn("w-3.5 h-3.5 transition-transform", showAdvanced && "rotate-180")}
|
||||
/>
|
||||
Advanced settings
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<SettingRow
|
||||
label="Temperature"
|
||||
description="Controls response randomness. Higher values produce more varied output."
|
||||
stack
|
||||
>
|
||||
<Slider
|
||||
value={settings.temperature}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={(v) => updateSettings({ temperature: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
web/components/settings/PermissionSettings.tsx
Normal file
128
web/components/settings/PermissionSettings.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useChatStore } from "@/lib/store";
|
||||
import { SettingRow, SectionHeader, Toggle } from "./SettingRow";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TOOL_LABELS: Record<string, { label: string; description: string }> = {
|
||||
file_read: {
|
||||
label: "File read",
|
||||
description: "Read files from the filesystem",
|
||||
},
|
||||
file_write: {
|
||||
label: "File write",
|
||||
description: "Create or modify files",
|
||||
},
|
||||
bash: {
|
||||
label: "Bash commands",
|
||||
description: "Execute shell commands",
|
||||
},
|
||||
web_search: {
|
||||
label: "Web search",
|
||||
description: "Search the internet",
|
||||
},
|
||||
};
|
||||
|
||||
export function PermissionSettings() {
|
||||
const { settings, updateSettings, resetSettings } = useChatStore();
|
||||
const [newDir, setNewDir] = useState("");
|
||||
|
||||
function toggleAutoApprove(tool: string, value: boolean) {
|
||||
updateSettings({
|
||||
permissions: {
|
||||
...settings.permissions,
|
||||
autoApprove: { ...settings.permissions.autoApprove, [tool]: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function addRestrictedDir() {
|
||||
const dir = newDir.trim();
|
||||
if (!dir || settings.permissions.restrictedDirs.includes(dir)) return;
|
||||
updateSettings({
|
||||
permissions: {
|
||||
...settings.permissions,
|
||||
restrictedDirs: [...settings.permissions.restrictedDirs, dir],
|
||||
},
|
||||
});
|
||||
setNewDir("");
|
||||
}
|
||||
|
||||
function removeRestrictedDir(dir: string) {
|
||||
updateSettings({
|
||||
permissions: {
|
||||
...settings.permissions,
|
||||
restrictedDirs: settings.permissions.restrictedDirs.filter((d) => d !== dir),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="Permissions & Safety" onReset={() => resetSettings("permissions")} />
|
||||
|
||||
<div className="mb-4 p-3 rounded-md bg-amber-500/10 border border-amber-500/20 text-xs text-amber-300">
|
||||
Auto-approving tools means Claude can perform these actions without asking for confirmation.
|
||||
Use with caution.
|
||||
</div>
|
||||
|
||||
{Object.entries(TOOL_LABELS).map(([tool, { label, description }]) => (
|
||||
<SettingRow key={tool} label={label} description={description}>
|
||||
<Toggle
|
||||
checked={!!settings.permissions.autoApprove[tool]}
|
||||
onChange={(v) => toggleAutoApprove(tool, v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
))}
|
||||
|
||||
<SettingRow
|
||||
label="Restricted directories"
|
||||
description="Limit file operations to specific directories. Leave empty for no restriction."
|
||||
stack
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{settings.permissions.restrictedDirs.map((dir) => (
|
||||
<div
|
||||
key={dir}
|
||||
className="flex items-center justify-between gap-2 px-2 py-1 rounded bg-surface-800 border border-surface-700"
|
||||
>
|
||||
<span className="text-xs font-mono text-surface-300 truncate">{dir}</span>
|
||||
<button
|
||||
onClick={() => removeRestrictedDir(dir)}
|
||||
className="text-surface-500 hover:text-red-400 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newDir}
|
||||
onChange={(e) => setNewDir(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addRestrictedDir()}
|
||||
placeholder="/path/to/directory"
|
||||
className={cn(
|
||||
"flex-1 bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 text-xs",
|
||||
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={addRestrictedDir}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs",
|
||||
"bg-surface-800 border border-surface-700 text-surface-300",
|
||||
"hover:text-surface-100 hover:bg-surface-700 transition-colors"
|
||||
)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
web/components/settings/SettingRow.tsx
Normal file
115
web/components/settings/SettingRow.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SettingRowProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
stack?: boolean;
|
||||
}
|
||||
|
||||
export function SettingRow({ label, description, children, className, stack = false }: SettingRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"py-4 border-b border-surface-800 last:border-0",
|
||||
stack ? "flex flex-col gap-3" : "flex items-start justify-between gap-6",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-surface-100">{label}</p>
|
||||
{description && (
|
||||
<p className="text-xs text-surface-400 mt-0.5 leading-relaxed">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex-shrink-0", stack && "w-full")}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export function SectionHeader({ title, onReset }: SectionHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-base font-semibold text-surface-100">{title}</h2>
|
||||
{onReset && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-xs text-surface-400 hover:text-surface-200 transition-colors"
|
||||
>
|
||||
Reset to defaults
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, disabled = false }: ToggleProps) {
|
||||
return (
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent",
|
||||
"transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked ? "bg-brand-600" : "bg-surface-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow",
|
||||
"transition duration-200 ease-in-out",
|
||||
checked ? "translate-x-4" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface SliderProps {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
showValue?: boolean;
|
||||
unit?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Slider({ value, min, max, step = 1, onChange, showValue = true, unit = "", className }: SliderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3", className)}>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="flex-1 h-1.5 bg-surface-700 rounded-full appearance-none cursor-pointer accent-brand-500"
|
||||
/>
|
||||
{showValue && (
|
||||
<span className="text-xs text-surface-300 w-12 text-right font-mono">
|
||||
{value}{unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
web/components/settings/SettingsNav.tsx
Normal file
74
web/components/settings/SettingsNav.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Settings,
|
||||
Cpu,
|
||||
Key,
|
||||
Shield,
|
||||
Server,
|
||||
Keyboard,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SettingsSection =
|
||||
| "general"
|
||||
| "model"
|
||||
| "api"
|
||||
| "permissions"
|
||||
| "mcp"
|
||||
| "keyboard"
|
||||
| "data";
|
||||
|
||||
interface NavItem {
|
||||
id: SettingsSection;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ id: "general", label: "General", icon: Settings },
|
||||
{ id: "model", label: "Model", icon: Cpu },
|
||||
{ id: "api", label: "API & Auth", icon: Key },
|
||||
{ id: "permissions", label: "Permissions", icon: Shield },
|
||||
{ id: "mcp", label: "MCP Servers", icon: Server },
|
||||
{ id: "keyboard", label: "Keyboard", icon: Keyboard },
|
||||
{ id: "data", label: "Data & Privacy", icon: Database },
|
||||
];
|
||||
|
||||
interface SettingsNavProps {
|
||||
active: SettingsSection;
|
||||
onChange: (section: SettingsSection) => void;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
export function SettingsNav({ active, onChange, searchQuery }: SettingsNavProps) {
|
||||
const filtered = searchQuery
|
||||
? NAV_ITEMS.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: NAV_ITEMS;
|
||||
|
||||
return (
|
||||
<nav className="w-48 flex-shrink-0 py-2">
|
||||
{filtered.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onChange(item.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors text-left",
|
||||
active === item.id
|
||||
? "bg-surface-800 text-surface-100"
|
||||
: "text-surface-400 hover:text-surface-200 hover:bg-surface-800/50"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
36
web/components/shortcuts/ShortcutBadge.tsx
Normal file
36
web/components/shortcuts/ShortcutBadge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { formatKeyCombo } from "@/lib/keyParser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShortcutBadgeProps {
|
||||
keys: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the first key combo for a command as a series of <kbd> elements.
|
||||
* E.g. "mod+shift+k" → [⌘] [⇧] [K]
|
||||
*/
|
||||
export function ShortcutBadge({ keys, className }: ShortcutBadgeProps) {
|
||||
if (keys.length === 0) return null;
|
||||
const parts = formatKeyCombo(keys[0]);
|
||||
|
||||
return (
|
||||
<span className={cn("flex items-center gap-0.5", className)}>
|
||||
{parts.map((part, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
"min-w-[1.375rem] h-5 px-1 rounded text-[10px] font-medium",
|
||||
"bg-surface-800 border border-surface-600 text-surface-400",
|
||||
"font-mono leading-none"
|
||||
)}
|
||||
>
|
||||
{part}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
113
web/components/shortcuts/ShortcutsHelp.tsx
Normal file
113
web/components/shortcuts/ShortcutsHelp.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { X, Search } from "lucide-react";
|
||||
import { useCommandRegistry } from "@/hooks/useCommandRegistry";
|
||||
import { ShortcutBadge } from "./ShortcutBadge";
|
||||
import { SHORTCUT_CATEGORIES } from "@/lib/shortcuts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ShortcutsHelp() {
|
||||
const { helpOpen, closeHelp, commands } = useCommandRegistry();
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const q = filter.toLowerCase();
|
||||
return SHORTCUT_CATEGORIES.map((cat) => ({
|
||||
category: cat,
|
||||
commands: commands.filter(
|
||||
(c) =>
|
||||
c.category === cat &&
|
||||
c.keys.length > 0 &&
|
||||
(!q ||
|
||||
c.label.toLowerCase().includes(q) ||
|
||||
c.description.toLowerCase().includes(q))
|
||||
),
|
||||
})).filter((g) => g.commands.length > 0);
|
||||
}, [commands, filter]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={helpOpen} onOpenChange={(open) => !open && closeHelp()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<Dialog.Content
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50",
|
||||
"w-full max-w-2xl max-h-[80vh] flex flex-col",
|
||||
"bg-surface-900 border border-surface-700 rounded-xl shadow-2xl",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-surface-800 flex-shrink-0">
|
||||
<div>
|
||||
<Dialog.Title className="text-sm font-semibold text-surface-100">
|
||||
Keyboard Shortcuts
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-xs text-surface-500 mt-0.5">
|
||||
Press <kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 text-[10px] font-mono">?</kbd> anytime to open this panel
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close className="p-1.5 rounded-md text-surface-500 hover:text-surface-100 hover:bg-surface-800 transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-2.5 border-b border-surface-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 bg-surface-800 rounded-lg px-3 py-1.5">
|
||||
<Search className="w-3.5 h-3.5 text-surface-500 flex-shrink-0" />
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter shortcuts..."
|
||||
className="flex-1 bg-transparent text-sm text-surface-100 placeholder:text-surface-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shortcut groups */}
|
||||
<div className="overflow-y-auto flex-1 px-4 py-3 space-y-5">
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-center text-sm text-surface-500 py-8">
|
||||
No shortcuts found
|
||||
</p>
|
||||
) : (
|
||||
groups.map(({ category, commands: cmds }) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-surface-600 mb-2">
|
||||
{category}
|
||||
</h3>
|
||||
<div className="rounded-lg border border-surface-800 overflow-hidden divide-y divide-surface-800">
|
||||
{cmds.map((cmd) => (
|
||||
<div
|
||||
key={cmd.id}
|
||||
className="flex items-center justify-between px-3 py-2 hover:bg-surface-800/50 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-surface-200">{cmd.label}</p>
|
||||
{cmd.description && (
|
||||
<p className="text-xs text-surface-500 mt-0.5">{cmd.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 ml-4 flex-shrink-0">
|
||||
{cmd.keys.map((k) => (
|
||||
<ShortcutBadge key={k} keys={[k]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
223
web/components/tools/AnsiRenderer.tsx
Normal file
223
web/components/tools/AnsiRenderer.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
// 16-color ANSI palette (matches common terminal defaults)
|
||||
const FG_COLORS: Record<number, string> = {
|
||||
30: "#3d3d3d",
|
||||
31: "#cc0000",
|
||||
32: "#4e9a06",
|
||||
33: "#c4a000",
|
||||
34: "#3465a4",
|
||||
35: "#75507b",
|
||||
36: "#06989a",
|
||||
37: "#d3d7cf",
|
||||
90: "#555753",
|
||||
91: "#ef2929",
|
||||
92: "#8ae234",
|
||||
93: "#fce94f",
|
||||
94: "#729fcf",
|
||||
95: "#ad7fa8",
|
||||
96: "#34e2e2",
|
||||
97: "#eeeeec",
|
||||
};
|
||||
|
||||
const BG_COLORS: Record<number, string> = {
|
||||
40: "#3d3d3d",
|
||||
41: "#cc0000",
|
||||
42: "#4e9a06",
|
||||
43: "#c4a000",
|
||||
44: "#3465a4",
|
||||
45: "#75507b",
|
||||
46: "#06989a",
|
||||
47: "#d3d7cf",
|
||||
100: "#555753",
|
||||
101: "#ef2929",
|
||||
102: "#8ae234",
|
||||
103: "#fce94f",
|
||||
104: "#729fcf",
|
||||
105: "#ad7fa8",
|
||||
106: "#34e2e2",
|
||||
107: "#eeeeec",
|
||||
};
|
||||
|
||||
// 256-color palette
|
||||
function get256Color(n: number): string {
|
||||
if (n < 16) {
|
||||
const fg = FG_COLORS[n + 30] ?? FG_COLORS[n + 82]; // handle 0-7 and 8-15
|
||||
if (fg) return fg;
|
||||
}
|
||||
if (n < 232) {
|
||||
// 6×6×6 color cube
|
||||
const i = n - 16;
|
||||
const b = i % 6;
|
||||
const g = Math.floor(i / 6) % 6;
|
||||
const r = Math.floor(i / 36);
|
||||
const toHex = (v: number) =>
|
||||
(v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, "0");
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
// Grayscale ramp
|
||||
const gray = (n - 232) * 10 + 8;
|
||||
const hex = gray.toString(16).padStart(2, "0");
|
||||
return `#${hex}${hex}${hex}`;
|
||||
}
|
||||
|
||||
interface AnsiStyle {
|
||||
color?: string;
|
||||
background?: string;
|
||||
bold?: boolean;
|
||||
dim?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strikethrough?: boolean;
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
text: string;
|
||||
style: AnsiStyle;
|
||||
}
|
||||
|
||||
function parseAnsi(input: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
let current: AnsiStyle = {};
|
||||
let pos = 0;
|
||||
let textStart = 0;
|
||||
|
||||
const pushSegment = (end: number) => {
|
||||
const text = input.slice(textStart, end);
|
||||
if (text) {
|
||||
segments.push({ text, style: { ...current } });
|
||||
}
|
||||
};
|
||||
|
||||
while (pos < input.length) {
|
||||
const esc = input.indexOf("\x1b[", pos);
|
||||
if (esc === -1) break;
|
||||
|
||||
pushSegment(esc);
|
||||
|
||||
// Find the end of the escape sequence (letter terminator)
|
||||
let seqEnd = esc + 2;
|
||||
while (seqEnd < input.length && !/[A-Za-z]/.test(input[seqEnd])) {
|
||||
seqEnd++;
|
||||
}
|
||||
const terminator = input[seqEnd];
|
||||
const params = input.slice(esc + 2, seqEnd).split(";").map(Number);
|
||||
|
||||
if (terminator === "m") {
|
||||
// SGR sequence
|
||||
let i = 0;
|
||||
while (i < params.length) {
|
||||
const p = params[i];
|
||||
if (p === 0 || isNaN(p)) {
|
||||
current = {};
|
||||
} else if (p === 1) {
|
||||
current.bold = true;
|
||||
} else if (p === 2) {
|
||||
current.dim = true;
|
||||
} else if (p === 3) {
|
||||
current.italic = true;
|
||||
} else if (p === 4) {
|
||||
current.underline = true;
|
||||
} else if (p === 9) {
|
||||
current.strikethrough = true;
|
||||
} else if (p === 22) {
|
||||
current.bold = false;
|
||||
current.dim = false;
|
||||
} else if (p === 23) {
|
||||
current.italic = false;
|
||||
} else if (p === 24) {
|
||||
current.underline = false;
|
||||
} else if (p === 29) {
|
||||
current.strikethrough = false;
|
||||
} else if (p >= 30 && p <= 37) {
|
||||
current.color = FG_COLORS[p];
|
||||
} else if (p === 38) {
|
||||
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
|
||||
current.color = get256Color(params[i + 2]);
|
||||
i += 2;
|
||||
} else if (
|
||||
params[i + 1] === 2 &&
|
||||
params[i + 2] !== undefined &&
|
||||
params[i + 3] !== undefined &&
|
||||
params[i + 4] !== undefined
|
||||
) {
|
||||
current.color = `rgb(${params[i + 2]},${params[i + 3]},${params[i + 4]})`;
|
||||
i += 4;
|
||||
}
|
||||
} else if (p === 39) {
|
||||
delete current.color;
|
||||
} else if (p >= 40 && p <= 47) {
|
||||
current.background = BG_COLORS[p];
|
||||
} else if (p === 48) {
|
||||
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
|
||||
current.background = get256Color(params[i + 2]);
|
||||
i += 2;
|
||||
} else if (
|
||||
params[i + 1] === 2 &&
|
||||
params[i + 2] !== undefined &&
|
||||
params[i + 3] !== undefined &&
|
||||
params[i + 4] !== undefined
|
||||
) {
|
||||
current.background = `rgb(${params[i + 2]},${params[i + 3]},${params[i + 4]})`;
|
||||
i += 4;
|
||||
}
|
||||
} else if (p === 49) {
|
||||
delete current.background;
|
||||
} else if (p >= 90 && p <= 97) {
|
||||
current.color = FG_COLORS[p];
|
||||
} else if (p >= 100 && p <= 107) {
|
||||
current.background = BG_COLORS[p];
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
pos = seqEnd + 1;
|
||||
textStart = pos;
|
||||
}
|
||||
|
||||
pushSegment(input.length);
|
||||
return segments;
|
||||
}
|
||||
|
||||
function segmentToStyle(style: AnsiStyle): React.CSSProperties {
|
||||
return {
|
||||
color: style.color,
|
||||
backgroundColor: style.background,
|
||||
fontWeight: style.bold ? "bold" : undefined,
|
||||
opacity: style.dim ? 0.7 : undefined,
|
||||
fontStyle: style.italic ? "italic" : undefined,
|
||||
textDecoration: [
|
||||
style.underline ? "underline" : "",
|
||||
style.strikethrough ? "line-through" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
interface AnsiRendererProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AnsiRenderer({ text, className }: AnsiRendererProps) {
|
||||
const lines = text.split("\n");
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{lines.map((line, lineIdx) => (
|
||||
<span key={lineIdx}>
|
||||
{lineIdx > 0 && "\n"}
|
||||
{parseAnsi(line).map((seg, segIdx) => (
|
||||
<span key={segIdx} style={segmentToStyle(seg.style)}>
|
||||
{seg.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
397
web/components/tools/DiffView.tsx
Normal file
397
web/components/tools/DiffView.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Columns2, AlignLeft, Copy, Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useHighlightedCode } from "./SyntaxHighlight";
|
||||
|
||||
// ─── Diff algorithm ──────────────────────────────────────────────────────────
|
||||
|
||||
type DiffLineType = "equal" | "add" | "remove";
|
||||
|
||||
interface DiffLine {
|
||||
type: DiffLineType;
|
||||
content: string;
|
||||
oldLineNo?: number;
|
||||
newLineNo?: number;
|
||||
}
|
||||
|
||||
function computeDiff(oldStr: string, newStr: string): DiffLine[] {
|
||||
const oldLines = oldStr.split("\n");
|
||||
const newLines = newStr.split("\n");
|
||||
const m = oldLines.length;
|
||||
const n = newLines.length;
|
||||
|
||||
// Build LCS table
|
||||
const dp: Uint32Array[] = Array.from(
|
||||
{ length: m + 1 },
|
||||
() => new Uint32Array(n + 1)
|
||||
);
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
if (oldLines[i - 1] === newLines[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||
} else {
|
||||
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backtrack to build diff
|
||||
const result: DiffLine[] = [];
|
||||
let i = m;
|
||||
let j = n;
|
||||
let oldLineNo = m;
|
||||
let newLineNo = n;
|
||||
|
||||
while (i > 0 || j > 0) {
|
||||
if (
|
||||
i > 0 &&
|
||||
j > 0 &&
|
||||
oldLines[i - 1] === newLines[j - 1]
|
||||
) {
|
||||
result.unshift({
|
||||
type: "equal",
|
||||
content: oldLines[i - 1],
|
||||
oldLineNo: oldLineNo--,
|
||||
newLineNo: newLineNo--,
|
||||
});
|
||||
i--;
|
||||
j--;
|
||||
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
||||
result.unshift({
|
||||
type: "add",
|
||||
content: newLines[j - 1],
|
||||
newLineNo: newLineNo--,
|
||||
});
|
||||
j--;
|
||||
} else {
|
||||
result.unshift({
|
||||
type: "remove",
|
||||
content: oldLines[i - 1],
|
||||
oldLineNo: oldLineNo--,
|
||||
});
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Copy button ─────────────────────────────────────────────────────────────
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1 rounded text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Unified diff view ───────────────────────────────────────────────────────
|
||||
|
||||
const CONTEXT_LINES = 3;
|
||||
|
||||
interface UnifiedDiffProps {
|
||||
lines: DiffLine[];
|
||||
lang: string;
|
||||
}
|
||||
|
||||
function UnifiedDiff({ lines, lang: _lang }: UnifiedDiffProps) {
|
||||
const [expandedHunks, setExpandedHunks] = useState<Set<number>>(new Set());
|
||||
|
||||
// Identify collapsed regions (equal lines away from changes)
|
||||
const visible = useMemo(() => {
|
||||
const changed = new Set<number>();
|
||||
lines.forEach((l, i) => {
|
||||
if (l.type !== "equal") {
|
||||
for (
|
||||
let k = Math.max(0, i - CONTEXT_LINES);
|
||||
k <= Math.min(lines.length - 1, i + CONTEXT_LINES);
|
||||
k++
|
||||
) {
|
||||
changed.add(k);
|
||||
}
|
||||
}
|
||||
});
|
||||
return changed;
|
||||
}, [lines]);
|
||||
|
||||
const items: Array<
|
||||
| { kind: "line"; line: DiffLine; idx: number }
|
||||
| { kind: "hunk"; start: number; end: number; count: number }
|
||||
> = useMemo(() => {
|
||||
const result: typeof items = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
if (visible.has(i) || expandedHunks.has(i)) {
|
||||
result.push({ kind: "line", line: lines[i], idx: i });
|
||||
i++;
|
||||
} else {
|
||||
// Find the extent of the collapsed hunk
|
||||
let end = i;
|
||||
while (end < lines.length && !visible.has(end) && !expandedHunks.has(end)) {
|
||||
end++;
|
||||
}
|
||||
result.push({ kind: "hunk", start: i, end, count: end - i });
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [lines, visible, expandedHunks]);
|
||||
|
||||
return (
|
||||
<div className="font-mono text-xs leading-5 overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<tbody>
|
||||
{items.map((item, idx) => {
|
||||
if (item.kind === "hunk") {
|
||||
return (
|
||||
<tr key={`hunk-${idx}`}>
|
||||
<td colSpan={3} className="bg-surface-800/50 text-center py-0.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setExpandedHunks((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (let k = item.start; k < item.end; k++) next.add(k);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="text-surface-400 hover:text-surface-200 text-xs px-2 py-0.5"
|
||||
>
|
||||
↕ {item.count} unchanged line{item.count !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const { line } = item;
|
||||
const bgClass =
|
||||
line.type === "add"
|
||||
? "bg-green-950/50 hover:bg-green-950/70"
|
||||
: line.type === "remove"
|
||||
? "bg-red-950/50 hover:bg-red-950/70"
|
||||
: "hover:bg-surface-800/30";
|
||||
const prefixClass =
|
||||
line.type === "add"
|
||||
? "text-green-400"
|
||||
: line.type === "remove"
|
||||
? "text-red-400"
|
||||
: "text-surface-600";
|
||||
const prefix =
|
||||
line.type === "add" ? "+" : line.type === "remove" ? "−" : " ";
|
||||
|
||||
return (
|
||||
<tr key={`line-${idx}`} className={bgClass}>
|
||||
{/* Old line number */}
|
||||
<td className="select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50">
|
||||
{line.type !== "add" ? line.oldLineNo : ""}
|
||||
</td>
|
||||
{/* New line number */}
|
||||
<td className="select-none text-right text-surface-600 pr-2 pl-2 w-10 border-r border-surface-700/50">
|
||||
{line.type !== "remove" ? line.newLineNo : ""}
|
||||
</td>
|
||||
{/* Content */}
|
||||
<td className="pl-3 pr-4 whitespace-pre">
|
||||
<span className={cn("mr-2", prefixClass)}>{prefix}</span>
|
||||
<span className="text-surface-100">{line.content}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Side-by-side diff ───────────────────────────────────────────────────────
|
||||
|
||||
interface SideBySideDiffProps {
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
function SideBySideDiff({ lines }: SideBySideDiffProps) {
|
||||
// Build paired columns: match adds to removes
|
||||
const pairs: Array<{
|
||||
left: DiffLine | null;
|
||||
right: DiffLine | null;
|
||||
}> = useMemo(() => {
|
||||
const result: Array<{ left: DiffLine | null; right: DiffLine | null }> = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.type === "equal") {
|
||||
result.push({ left: line, right: line });
|
||||
i++;
|
||||
} else if (line.type === "remove") {
|
||||
// Pair with next add if exists
|
||||
const next = lines[i + 1];
|
||||
if (next?.type === "add") {
|
||||
result.push({ left: line, right: next });
|
||||
i += 2;
|
||||
} else {
|
||||
result.push({ left: line, right: null });
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
result.push({ left: null, right: line });
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [lines]);
|
||||
|
||||
return (
|
||||
<div className="font-mono text-xs leading-5 overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="text-surface-500 border-b border-surface-700">
|
||||
<th colSpan={2} className="text-left pl-3 py-1 font-normal">
|
||||
Before
|
||||
</th>
|
||||
<th colSpan={2} className="text-left pl-3 py-1 font-normal border-l border-surface-700">
|
||||
After
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pairs.map((pair, idx) => (
|
||||
<tr key={idx}>
|
||||
{/* Left column */}
|
||||
<td
|
||||
className={cn(
|
||||
"select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50",
|
||||
pair.left?.type === "remove" && "bg-red-950/50"
|
||||
)}
|
||||
>
|
||||
{pair.left?.oldLineNo ?? ""}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"pl-2 pr-3 whitespace-pre border-r border-surface-700",
|
||||
pair.left?.type === "remove"
|
||||
? "bg-red-950/50 text-red-200"
|
||||
: "text-surface-300"
|
||||
)}
|
||||
>
|
||||
{pair.left?.content ?? ""}
|
||||
</td>
|
||||
{/* Right column */}
|
||||
<td
|
||||
className={cn(
|
||||
"select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50",
|
||||
pair.right?.type === "add" && "bg-green-950/50"
|
||||
)}
|
||||
>
|
||||
{pair.right?.newLineNo ?? ""}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
"pl-2 pr-3 whitespace-pre",
|
||||
pair.right?.type === "add"
|
||||
? "bg-green-950/50 text-green-200"
|
||||
: "text-surface-300"
|
||||
)}
|
||||
>
|
||||
{pair.right?.content ?? ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Public component ────────────────────────────────────────────────────────
|
||||
|
||||
interface DiffViewProps {
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
lang?: string;
|
||||
defaultMode?: "unified" | "side-by-side";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DiffView({
|
||||
oldContent,
|
||||
newContent,
|
||||
lang = "text",
|
||||
defaultMode = "unified",
|
||||
className,
|
||||
}: DiffViewProps) {
|
||||
const [mode, setMode] = useState<"unified" | "side-by-side">(defaultMode);
|
||||
|
||||
const lines = useMemo(
|
||||
() => computeDiff(oldContent, newContent),
|
||||
[oldContent, newContent]
|
||||
);
|
||||
|
||||
const addCount = lines.filter((l) => l.type === "add").length;
|
||||
const removeCount = lines.filter((l) => l.type === "remove").length;
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg overflow-hidden border border-surface-700", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-surface-800 border-b border-surface-700">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-green-400 font-mono">+{addCount}</span>
|
||||
<span className="text-red-400 font-mono">−{removeCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<CopyButton text={newContent} />
|
||||
<div className="flex items-center rounded overflow-hidden border border-surface-700">
|
||||
<button
|
||||
onClick={() => setMode("unified")}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs flex items-center gap-1 transition-colors",
|
||||
mode === "unified"
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-surface-400 hover:text-surface-200 hover:bg-surface-700"
|
||||
)}
|
||||
>
|
||||
<AlignLeft className="w-3 h-3" />
|
||||
Unified
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode("side-by-side")}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs flex items-center gap-1 transition-colors",
|
||||
mode === "side-by-side"
|
||||
? "bg-brand-600 text-white"
|
||||
: "text-surface-400 hover:text-surface-200 hover:bg-surface-700"
|
||||
)}
|
||||
>
|
||||
<Columns2 className="w-3 h-3" />
|
||||
Side by side
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff content */}
|
||||
<div className="bg-surface-900 overflow-auto max-h-[480px]">
|
||||
{mode === "unified" ? (
|
||||
<UnifiedDiff lines={lines} lang={lang} />
|
||||
) : (
|
||||
<SideBySideDiff lines={lines} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
web/components/tools/FileIcon.tsx
Normal file
106
web/components/tools/FileIcon.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FileText,
|
||||
FileCode,
|
||||
FileJson,
|
||||
FileImage,
|
||||
File,
|
||||
Database,
|
||||
Settings,
|
||||
Package,
|
||||
Globe,
|
||||
BookOpen,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const EXT_MAP: Record<string, LucideIcon> = {
|
||||
// JavaScript / TypeScript
|
||||
js: FileCode,
|
||||
jsx: FileCode,
|
||||
ts: FileCode,
|
||||
tsx: FileCode,
|
||||
mjs: FileCode,
|
||||
cjs: FileCode,
|
||||
// Web
|
||||
html: Globe,
|
||||
htm: Globe,
|
||||
css: FileCode,
|
||||
scss: FileCode,
|
||||
sass: FileCode,
|
||||
less: FileCode,
|
||||
// Data
|
||||
json: FileJson,
|
||||
jsonc: FileJson,
|
||||
yaml: FileJson,
|
||||
yml: FileJson,
|
||||
toml: FileJson,
|
||||
xml: FileJson,
|
||||
csv: Database,
|
||||
// Config
|
||||
env: Settings,
|
||||
gitignore: Settings,
|
||||
eslintrc: Settings,
|
||||
prettierrc: Settings,
|
||||
editorconfig: Settings,
|
||||
// Docs
|
||||
md: BookOpen,
|
||||
mdx: BookOpen,
|
||||
txt: FileText,
|
||||
rst: FileText,
|
||||
// Images
|
||||
png: FileImage,
|
||||
jpg: FileImage,
|
||||
jpeg: FileImage,
|
||||
gif: FileImage,
|
||||
svg: FileImage,
|
||||
ico: FileImage,
|
||||
webp: FileImage,
|
||||
// Package
|
||||
lock: Package,
|
||||
// Python
|
||||
py: FileCode,
|
||||
pyc: FileCode,
|
||||
// Ruby
|
||||
rb: FileCode,
|
||||
// Go
|
||||
go: FileCode,
|
||||
// Rust
|
||||
rs: FileCode,
|
||||
// Java / Kotlin
|
||||
java: FileCode,
|
||||
kt: FileCode,
|
||||
// C / C++
|
||||
c: FileCode,
|
||||
cpp: FileCode,
|
||||
h: FileCode,
|
||||
hpp: FileCode,
|
||||
// Shell
|
||||
sh: FileCode,
|
||||
bash: FileCode,
|
||||
zsh: FileCode,
|
||||
fish: FileCode,
|
||||
// SQL
|
||||
sql: Database,
|
||||
};
|
||||
|
||||
function getExtension(filePath: string): string {
|
||||
const parts = filePath.split(".");
|
||||
if (parts.length < 2) return "";
|
||||
return parts[parts.length - 1].toLowerCase();
|
||||
}
|
||||
|
||||
export function getFileIcon(filePath: string): LucideIcon {
|
||||
const ext = getExtension(filePath);
|
||||
return EXT_MAP[ext] ?? File;
|
||||
}
|
||||
|
||||
interface FileIconProps {
|
||||
filePath: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FileIcon({ filePath, className }: FileIconProps) {
|
||||
const Icon = getFileIcon(filePath);
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
161
web/components/tools/SyntaxHighlight.tsx
Normal file
161
web/components/tools/SyntaxHighlight.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import type { Highlighter } from "shiki";
|
||||
|
||||
// Singleton highlighter promise so we only init once
|
||||
let highlighterPromise: Promise<Highlighter> | null = null;
|
||||
|
||||
async function getHighlighter(): Promise<Highlighter> {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = import("shiki").then((shiki) =>
|
||||
shiki.createHighlighter({
|
||||
themes: ["github-dark", "github-light"],
|
||||
langs: [
|
||||
"typescript",
|
||||
"javascript",
|
||||
"tsx",
|
||||
"jsx",
|
||||
"python",
|
||||
"rust",
|
||||
"go",
|
||||
"java",
|
||||
"c",
|
||||
"cpp",
|
||||
"ruby",
|
||||
"shell",
|
||||
"bash",
|
||||
"json",
|
||||
"yaml",
|
||||
"toml",
|
||||
"css",
|
||||
"html",
|
||||
"markdown",
|
||||
"sql",
|
||||
"dockerfile",
|
||||
"kotlin",
|
||||
"swift",
|
||||
"php",
|
||||
"xml",
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
// Map file extension to shiki language
|
||||
const EXT_TO_LANG: Record<string, string> = {
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
py: "python",
|
||||
rs: "rust",
|
||||
go: "go",
|
||||
java: "java",
|
||||
c: "c",
|
||||
cpp: "cpp",
|
||||
h: "c",
|
||||
hpp: "cpp",
|
||||
rb: "ruby",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
json: "json",
|
||||
jsonc: "json",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
css: "css",
|
||||
scss: "css",
|
||||
html: "html",
|
||||
htm: "html",
|
||||
md: "markdown",
|
||||
mdx: "markdown",
|
||||
sql: "sql",
|
||||
kt: "kotlin",
|
||||
swift: "swift",
|
||||
php: "php",
|
||||
xml: "xml",
|
||||
dockerfile: "dockerfile",
|
||||
};
|
||||
|
||||
export function getLanguageFromPath(filePath: string): string {
|
||||
const name = filePath.split("/").pop() ?? "";
|
||||
if (name.toLowerCase() === "dockerfile") return "dockerfile";
|
||||
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
||||
return EXT_TO_LANG[ext] ?? "text";
|
||||
}
|
||||
|
||||
interface UseHighlightedCodeOptions {
|
||||
code: string;
|
||||
lang: string;
|
||||
theme?: "github-dark" | "github-light";
|
||||
}
|
||||
|
||||
export function useHighlightedCode({
|
||||
code,
|
||||
lang,
|
||||
theme = "github-dark",
|
||||
}: UseHighlightedCodeOptions): string | null {
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const lastKey = useRef<string>("");
|
||||
const key = `${lang}:${theme}:${code}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (lastKey.current === key) return;
|
||||
lastKey.current = key;
|
||||
|
||||
let cancelled = false;
|
||||
getHighlighter().then((hl) => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const highlighted = hl.codeToHtml(code, { lang, theme });
|
||||
if (!cancelled) setHtml(highlighted);
|
||||
} catch {
|
||||
// Language not supported — fall back to plain
|
||||
if (!cancelled) setHtml(null);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [key, code, lang, theme]);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
interface SyntaxHighlightProps {
|
||||
code: string;
|
||||
lang: string;
|
||||
theme?: "github-dark" | "github-light";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SyntaxHighlight({
|
||||
code,
|
||||
lang,
|
||||
theme = "github-dark",
|
||||
className,
|
||||
}: SyntaxHighlightProps) {
|
||||
const html = useHighlightedCode({ code, lang, theme });
|
||||
|
||||
if (html) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
// shiki wraps output in <pre><code> already
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className={className}>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
157
web/components/tools/ToolBash.tsx
Normal file
157
web/components/tools/ToolBash.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Check, Clock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AnsiRenderer } from "./AnsiRenderer";
|
||||
import { ToolUseBlock } from "./ToolUseBlock";
|
||||
|
||||
interface ToolBashProps {
|
||||
input: {
|
||||
command: string;
|
||||
timeout?: number;
|
||||
description?: string;
|
||||
};
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
isRunning?: boolean;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
className="p-1 rounded text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors"
|
||||
title="Copy command"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Parse exit code from result — bash tool often appends it
|
||||
function parseExitCode(result: string): number | null {
|
||||
const match = result.match(/\nExit code: (\d+)\s*$/);
|
||||
if (match) return parseInt(match[1], 10);
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripExitCode(result: string): string {
|
||||
return result.replace(/\nExit code: \d+\s*$/, "");
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_LINES = 200;
|
||||
|
||||
export function ToolBash({
|
||||
input,
|
||||
result,
|
||||
isError = false,
|
||||
isRunning = false,
|
||||
startedAt,
|
||||
completedAt,
|
||||
}: ToolBashProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const exitCode = result ? parseExitCode(result) : null;
|
||||
const outputText = result ? stripExitCode(result) : "";
|
||||
const outputLines = outputText.split("\n");
|
||||
const isTruncated = !showAll && outputLines.length > MAX_OUTPUT_LINES;
|
||||
const displayOutput = isTruncated
|
||||
? outputLines.slice(0, MAX_OUTPUT_LINES).join("\n")
|
||||
: outputText;
|
||||
|
||||
// Determine if it's an error (non-zero exit code or isError prop)
|
||||
const hasError = isError || (exitCode !== null && exitCode !== 0);
|
||||
|
||||
return (
|
||||
<ToolUseBlock
|
||||
toolName="bash"
|
||||
toolInput={input}
|
||||
toolResult={result}
|
||||
isError={hasError}
|
||||
isRunning={isRunning}
|
||||
startedAt={startedAt}
|
||||
completedAt={completedAt}
|
||||
>
|
||||
{/* Command display */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
|
||||
<span className="text-brand-400 font-mono text-xs select-none">$</span>
|
||||
<code className="font-mono text-xs text-surface-100 flex-1 break-all">
|
||||
{input.command}
|
||||
</code>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{input.timeout && (
|
||||
<span
|
||||
className="flex items-center gap-1 text-xs text-surface-500"
|
||||
title={`Timeout: ${input.timeout}ms`}
|
||||
>
|
||||
<Clock className="w-3 h-3" />
|
||||
{(input.timeout / 1000).toFixed(0)}s
|
||||
</span>
|
||||
)}
|
||||
<CopyButton text={input.command} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
{isRunning ? (
|
||||
<div className="px-3 py-3 font-mono text-xs text-brand-400 animate-pulse-soft">
|
||||
Running…
|
||||
</div>
|
||||
) : outputText ? (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-auto max-h-[400px] bg-[#0d0d0d] px-3 py-3",
|
||||
"font-mono text-xs leading-5 whitespace-pre"
|
||||
)}
|
||||
>
|
||||
<AnsiRenderer text={displayOutput} />
|
||||
{isTruncated && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="mt-2 block text-brand-400 hover:text-brand-300 text-xs"
|
||||
>
|
||||
↓ Show {outputLines.length - MAX_OUTPUT_LINES} more lines
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: exit code */}
|
||||
{exitCode !== null && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 border-t border-surface-700/50",
|
||||
exitCode === 0 ? "bg-surface-850" : "bg-red-950/20"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-surface-500">Exit code</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs px-1.5 py-0.5 rounded",
|
||||
exitCode === 0
|
||||
? "bg-green-900/40 text-green-400"
|
||||
: "bg-red-900/40 text-red-400"
|
||||
)}
|
||||
>
|
||||
{exitCode}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</ToolUseBlock>
|
||||
);
|
||||
}
|
||||
95
web/components/tools/ToolFileEdit.tsx
Normal file
95
web/components/tools/ToolFileEdit.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { FileIcon } from "./FileIcon";
|
||||
import { DiffView } from "./DiffView";
|
||||
import { getLanguageFromPath } from "./SyntaxHighlight";
|
||||
import { ToolUseBlock } from "./ToolUseBlock";
|
||||
|
||||
interface ToolFileEditProps {
|
||||
input: {
|
||||
file_path: string;
|
||||
old_string: string;
|
||||
new_string: string;
|
||||
replace_all?: boolean;
|
||||
};
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
isRunning?: boolean;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
function FileBreadcrumb({ filePath }: { filePath: string }) {
|
||||
const parts = filePath.replace(/^\//, "").split("/");
|
||||
return (
|
||||
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
|
||||
{parts.map((part, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
|
||||
<span
|
||||
className={i === parts.length - 1 ? "text-surface-200 font-medium" : ""}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolFileEdit({
|
||||
input,
|
||||
result,
|
||||
isError = false,
|
||||
isRunning = false,
|
||||
startedAt,
|
||||
completedAt,
|
||||
}: ToolFileEditProps) {
|
||||
const lang = getLanguageFromPath(input.file_path);
|
||||
|
||||
return (
|
||||
<ToolUseBlock
|
||||
toolName="edit"
|
||||
toolInput={input}
|
||||
toolResult={result}
|
||||
isError={isError}
|
||||
isRunning={isRunning}
|
||||
startedAt={startedAt}
|
||||
completedAt={completedAt}
|
||||
>
|
||||
{/* File path header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
|
||||
<FileIcon
|
||||
filePath={input.file_path}
|
||||
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
|
||||
/>
|
||||
<FileBreadcrumb filePath={input.file_path} />
|
||||
{input.replace_all && (
|
||||
<span className="ml-auto text-xs px-1.5 py-0.5 rounded bg-surface-700 text-surface-300">
|
||||
replace all
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isRunning ? (
|
||||
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
|
||||
Editing…
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="px-3 py-3 text-red-400 text-xs font-mono whitespace-pre-wrap">
|
||||
{result}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3">
|
||||
<DiffView
|
||||
oldContent={input.old_string}
|
||||
newContent={input.new_string}
|
||||
lang={lang}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ToolUseBlock>
|
||||
);
|
||||
}
|
||||
119
web/components/tools/ToolFileRead.tsx
Normal file
119
web/components/tools/ToolFileRead.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FileIcon } from "./FileIcon";
|
||||
import { SyntaxHighlight, getLanguageFromPath } from "./SyntaxHighlight";
|
||||
import { ToolUseBlock } from "./ToolUseBlock";
|
||||
|
||||
interface ToolFileReadProps {
|
||||
input: {
|
||||
file_path: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
};
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
isRunning?: boolean;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
function FileBreadcrumb({ filePath }: { filePath: string }) {
|
||||
const parts = filePath.replace(/^\//, "").split("/");
|
||||
return (
|
||||
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
|
||||
{parts.map((part, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
|
||||
<span
|
||||
className={
|
||||
i === parts.length - 1 ? "text-surface-200 font-medium" : ""
|
||||
}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_LINES_COLLAPSED = 40;
|
||||
|
||||
export function ToolFileRead({
|
||||
input,
|
||||
result,
|
||||
isError = false,
|
||||
isRunning = false,
|
||||
startedAt,
|
||||
completedAt,
|
||||
}: ToolFileReadProps) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const lang = getLanguageFromPath(input.file_path);
|
||||
|
||||
const lines = result?.split("\n") ?? [];
|
||||
const isTruncated = !showAll && lines.length > MAX_LINES_COLLAPSED;
|
||||
const displayContent = isTruncated
|
||||
? lines.slice(0, MAX_LINES_COLLAPSED).join("\n")
|
||||
: (result ?? "");
|
||||
|
||||
return (
|
||||
<ToolUseBlock
|
||||
toolName="read"
|
||||
toolInput={input}
|
||||
toolResult={result}
|
||||
isError={isError}
|
||||
isRunning={isRunning}
|
||||
startedAt={startedAt}
|
||||
completedAt={completedAt}
|
||||
>
|
||||
{/* File path header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
|
||||
<FileIcon
|
||||
filePath={input.file_path}
|
||||
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
|
||||
/>
|
||||
<FileBreadcrumb filePath={input.file_path} />
|
||||
{(input.offset !== undefined || input.limit !== undefined) && (
|
||||
<span className="ml-auto text-xs text-surface-500 flex-shrink-0">
|
||||
{input.offset !== undefined && `offset: ${input.offset}`}
|
||||
{input.offset !== undefined && input.limit !== undefined && " · "}
|
||||
{input.limit !== undefined && `limit: ${input.limit}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isRunning ? (
|
||||
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
|
||||
Reading…
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
|
||||
) : result ? (
|
||||
<div className="relative">
|
||||
<div className="overflow-auto max-h-[480px] [&_pre]:!bg-transparent [&_pre]:!m-0 [&_.shiki]:!bg-transparent">
|
||||
<SyntaxHighlight
|
||||
code={displayContent}
|
||||
lang={lang}
|
||||
className="text-xs [&_pre]:p-3 [&_pre]:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{isTruncated && (
|
||||
<div className="flex justify-center py-2 border-t border-surface-700/50 bg-surface-850">
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="text-xs text-brand-400 hover:text-brand-300 flex items-center gap-1"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
Show {lines.length - MAX_LINES_COLLAPSED} more lines
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</ToolUseBlock>
|
||||
);
|
||||
}
|
||||
103
web/components/tools/ToolFileWrite.tsx
Normal file
103
web/components/tools/ToolFileWrite.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { FileIcon } from "./FileIcon";
|
||||
import { SyntaxHighlight, getLanguageFromPath } from "./SyntaxHighlight";
|
||||
import { ToolUseBlock } from "./ToolUseBlock";
|
||||
|
||||
interface ToolFileWriteProps {
|
||||
input: {
|
||||
file_path: string;
|
||||
content: string;
|
||||
};
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
isRunning?: boolean;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
function FileBreadcrumb({ filePath }: { filePath: string }) {
|
||||
const parts = filePath.replace(/^\//, "").split("/");
|
||||
return (
|
||||
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
|
||||
{parts.map((part, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
|
||||
<span
|
||||
className={i === parts.length - 1 ? "text-surface-200 font-medium" : ""}
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isNewFile(result?: string): boolean {
|
||||
if (!result) return false;
|
||||
return /creat|new/i.test(result);
|
||||
}
|
||||
|
||||
export function ToolFileWrite({
|
||||
input,
|
||||
result,
|
||||
isError = false,
|
||||
isRunning = false,
|
||||
startedAt,
|
||||
completedAt,
|
||||
}: ToolFileWriteProps) {
|
||||
const lang = getLanguageFromPath(input.file_path);
|
||||
const lineCount = input.content.split("\n").length;
|
||||
|
||||
return (
|
||||
<ToolUseBlock
|
||||
toolName="write"
|
||||
toolInput={input}
|
||||
toolResult={result}
|
||||
isError={isError}
|
||||
isRunning={isRunning}
|
||||
startedAt={startedAt}
|
||||
completedAt={completedAt}
|
||||
>
|
||||
{/* File path header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
|
||||
<FileIcon
|
||||
filePath={input.file_path}
|
||||
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
|
||||
/>
|
||||
<FileBreadcrumb filePath={input.file_path} />
|
||||
<div className="ml-auto flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs text-surface-500">{lineCount} lines</span>
|
||||
<span
|
||||
className={
|
||||
isNewFile(result)
|
||||
? "text-xs px-1.5 py-0.5 rounded bg-green-900/40 text-green-400 border border-green-800/50"
|
||||
: "text-xs px-1.5 py-0.5 rounded bg-yellow-900/30 text-yellow-400 border border-yellow-800/40"
|
||||
}
|
||||
>
|
||||
{isNewFile(result) ? "New file" : "Overwrite"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isRunning ? (
|
||||
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
|
||||
Writing…
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
|
||||
) : (
|
||||
<div className="overflow-auto max-h-[480px] [&_pre]:!bg-transparent [&_pre]:!m-0 [&_.shiki]:!bg-transparent">
|
||||
<SyntaxHighlight
|
||||
code={input.content}
|
||||
lang={lang}
|
||||
className="text-xs [&_pre]:p-3 [&_pre]:leading-5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ToolUseBlock>
|
||||
);
|
||||
}
|
||||
88
web/components/tools/ToolGlob.tsx
Normal file
88
web/components/tools/ToolGlob.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { FileIcon } from "./FileIcon";
|
||||
import { ToolUseBlock } from "./ToolUseBlock";
|
||||
|
||||
interface ToolGlobProps {
|
||||
input: {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
};
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
isRunning?: boolean;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
function parseFilePaths(result: string): string[] {
|
||||
return result
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function ToolGlob({
|
||||
input,
|
||||
result,
|
||||
isError = false,
|
||||
isRunning = false,
|
||||
startedAt,
|
||||
completedAt,
|
||||
}: ToolGlobProps) {
|
||||
const files = result ? parseFilePaths(result) : [];
|
||||
const fileCount = files.length;
|
||||
|
||||
return (
|
||||
<ToolUseBlock
|
||||
toolName="glob"
|
||||
toolInput={input}
|
||||
toolResult={result}
|
||||
isError={isError}
|
||||
isRunning={isRunning}
|
||||
startedAt={startedAt}
|
||||
completedAt={completedAt}
|
||||
>
|
||||
{/* Pattern header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
|
||||
<code className="font-mono text-xs text-brand-300">{input.pattern}</code>
|
||||
{input.path && (
|
||||
<span className="text-xs text-surface-500">in {input.path}</span>
|
||||
)}
|
||||
{!isRunning && fileCount > 0 && (
|
||||
<span className="ml-auto text-xs text-surface-500">
|
||||
{fileCount} match{fileCount !== 1 ? "es" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
{isRunning ? (
|
||||
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
|
||||
Searching…
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="px-3 py-3 text-surface-500 text-xs">No matches found.</div>
|
||||
) : (
|
||||
<div className="overflow-auto max-h-[320px] py-1">
|
||||
{files.map((filePath, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-3 py-1 hover:bg-surface-800/50 transition-colors"
|
||||
>
|
||||
<FileIcon
|
||||
filePath={filePath}
|
||||
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
|
||||
/>
|
||||
<span className="font-mono text-xs text-surface-200 truncate">
|
||||
{filePath}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ToolUseBlock>
|
||||
);
|
||||
}
|
||||
182
web/components/tools/ToolGrep.tsx
Normal file
182
web/components/tools/ToolGrep.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { FileIcon } from "./FileIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ToolUseBlock } from "./ToolUseBlock";
|
||||
|
||||
interface ToolGrepProps {
|
||||
input: {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
type?: string;
|
||||
output_mode?: string;
|
||||
"-i"?: boolean;
|
||||
"-n"?: boolean;
|
||||
context?: number;
|
||||
"-A"?: number;
|
||||
"-B"?: number;
|
||||
"-C"?: number;
|
||||
};
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
isRunning?: boolean;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
interface GrepMatch {
|
||||
file: string;
|
||||
lineNo?: number;
|
||||
content: string;
|
||||
isContext?: boolean;
|
||||
}
|
||||
|
||||
interface GrepGroup {
|
||||
file: string;
|
||||
matches: GrepMatch[];
|
||||
}
|
||||
|
||||
function parseGrepOutput(result: string): GrepGroup[] {
|
||||
const lines = result.split("\n").filter(Boolean);
|
||||
const groups: Map<string, GrepMatch[]> = new Map();
|
||||
|
||||
for (const line of lines) {
|
||||
// Format: "file:lineNo:content" or "file:content" or just "file"
|
||||
const colonMatch = line.match(/^([^:]+):(\d+):(.*)$/);
|
||||
if (colonMatch) {
|
||||
const [, file, lineNo, content] = colonMatch;
|
||||
if (!groups.has(file)) groups.set(file, []);
|
||||
groups.get(file)!.push({ file, lineNo: parseInt(lineNo, 10), content });
|
||||
} else if (line.match(/^[^:]+$/)) {
|
||||
// Files-only mode
|
||||
if (!groups.has(line)) groups.set(line, []);
|
||||
} else {
|
||||
// fallback: treat entire line as content with unknown file
|
||||
if (!groups.has("")) groups.set("", []);
|
||||
groups.get("")!.push({ file: "", content: line });
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([file, matches]) => ({
|
||||
file,
|
||||
matches,
|
||||
}));
|
||||
}
|
||||
|
||||
function highlightPattern(text: string, pattern: string): React.ReactNode {
|
||||
try {
|
||||
const re = new RegExp(`(${pattern})`, "gi");
|
||||
const parts = text.split(re);
|
||||
return parts.map((part, i) =>
|
||||
re.test(part) ? (
|
||||
<mark key={i} className="bg-yellow-500/30 text-yellow-200 rounded-sm">
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolGrep({
|
||||
input,
|
||||
result,
|
||||
isError = false,
|
||||
isRunning = false,
|
||||
startedAt,
|
||||
completedAt,
|
||||
}: ToolGrepProps) {
|
||||
const groups = result ? parseGrepOutput(result) : [];
|
||||
const totalMatches = groups.reduce((sum, g) => sum + g.matches.length, 0);
|
||||
|
||||
const flags = [
|
||||
input["-i"] && "-i",
|
||||
input["-n"] !== false && "-n",
|
||||
input.glob && `--glob ${input.glob}`,
|
||||
input.type && `--type ${input.type}`,
|
||||
input.context && `-C ${input.context}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<ToolUseBlock
|
||||
toolName="grep"
|
||||
toolInput={input}
|
||||
toolResult={result}
|
||||
isError={isError}
|
||||
isRunning={isRunning}
|
||||
startedAt={startedAt}
|
||||
completedAt={completedAt}
|
||||
>
|
||||
{/* Search header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50 flex-wrap">
|
||||
<code className="font-mono text-xs text-yellow-300">{input.pattern}</code>
|
||||
{flags && <span className="text-xs text-surface-500 font-mono">{flags}</span>}
|
||||
{input.path && (
|
||||
<span className="text-xs text-surface-500">in {input.path}</span>
|
||||
)}
|
||||
{!isRunning && totalMatches > 0 && (
|
||||
<span className="ml-auto text-xs text-surface-500">
|
||||
{totalMatches} match{totalMatches !== 1 ? "es" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isRunning ? (
|
||||
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
|
||||
Searching…
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="px-3 py-3 text-surface-500 text-xs">No matches found.</div>
|
||||
) : (
|
||||
<div className="overflow-auto max-h-[400px]">
|
||||
{groups.map((group, gi) => (
|
||||
<div key={gi} className="border-b border-surface-700/40 last:border-0">
|
||||
{/* File header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface-800/40 sticky top-0">
|
||||
<FileIcon
|
||||
filePath={group.file}
|
||||
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
|
||||
/>
|
||||
<span className="font-mono text-xs text-surface-300 truncate">
|
||||
{group.file || "(unknown)"}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-surface-500 flex-shrink-0">
|
||||
{group.matches.length} match{group.matches.length !== 1 ? "es" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Match lines */}
|
||||
{group.matches.map((match, mi) => (
|
||||
<div
|
||||
key={mi}
|
||||
className={cn(
|
||||
"flex font-mono text-xs leading-5 hover:bg-surface-800/30",
|
||||
match.isContext ? "text-surface-500" : "text-surface-200"
|
||||
)}
|
||||
>
|
||||
{match.lineNo !== undefined && (
|
||||
<span className="select-none text-right text-surface-600 pr-2 pl-3 w-12 border-r border-surface-700/50 flex-shrink-0">
|
||||
{match.lineNo}
|
||||
</span>
|
||||
)}
|
||||
<span className="pl-3 pr-4 py-0.5 whitespace-pre">
|
||||
{highlightPattern(match.content, input.pattern)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ToolUseBlock>
|
||||
);
|
||||
}
|
||||
247
web/components/tools/ToolUseBlock.tsx
Normal file
247
web/components/tools/ToolUseBlock.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Terminal,
|
||||
FileText,
|
||||
FileEdit,
|
||||
FileSearch,
|
||||
Search,
|
||||
Globe,
|
||||
BookOpen,
|
||||
ClipboardList,
|
||||
Bot,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Tool icon mapping ────────────────────────────────────────────────────────
|
||||
|
||||
const TOOL_ICONS: Record<string, React.ElementType> = {
|
||||
bash: Terminal,
|
||||
read: FileText,
|
||||
write: FileText,
|
||||
edit: FileEdit,
|
||||
glob: FileSearch,
|
||||
grep: Search,
|
||||
webfetch: Globe,
|
||||
websearch: Globe,
|
||||
notebookedit: BookOpen,
|
||||
todowrite: ClipboardList,
|
||||
agent: Bot,
|
||||
};
|
||||
|
||||
function getToolIcon(name: string): React.ElementType {
|
||||
return TOOL_ICONS[name.toLowerCase()] ?? Wrench;
|
||||
}
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
bash: "Bash",
|
||||
read: "Read File",
|
||||
write: "Write File",
|
||||
edit: "Edit File",
|
||||
glob: "Glob",
|
||||
grep: "Grep",
|
||||
webfetch: "Web Fetch",
|
||||
websearch: "Web Search",
|
||||
notebookedit: "Notebook Edit",
|
||||
todowrite: "Todo",
|
||||
agent: "Agent",
|
||||
};
|
||||
|
||||
function getToolLabel(name: string): string {
|
||||
return TOOL_LABELS[name.toLowerCase()] ?? name;
|
||||
}
|
||||
|
||||
// ─── Elapsed timer ────────────────────────────────────────────────────────────
|
||||
|
||||
function ElapsedTimer({ startMs }: { startMs: number }) {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setElapsed(Date.now() - startMs);
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, [startMs]);
|
||||
|
||||
if (elapsed < 1000) return <span>{elapsed}ms</span>;
|
||||
return <span>{(elapsed / 1000).toFixed(1)}s</span>;
|
||||
}
|
||||
|
||||
// ─── Status badge ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatusBadgeProps {
|
||||
isRunning: boolean;
|
||||
isError: boolean;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
isRunning,
|
||||
isError,
|
||||
startedAt,
|
||||
completedAt,
|
||||
}: StatusBadgeProps) {
|
||||
if (isRunning) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs text-brand-400">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<ElapsedTimer startMs={startedAt} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const duration = completedAt ? completedAt - startedAt : null;
|
||||
const durationStr = duration
|
||||
? duration < 1000
|
||||
? `${duration}ms`
|
||||
: `${(duration / 1000).toFixed(1)}s`
|
||||
: null;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs text-red-400">
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
{durationStr && <span>{durationStr}</span>}
|
||||
<span>Error</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
{durationStr && <span>{durationStr}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ToolUseBlockProps {
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
toolResult?: string | null;
|
||||
isError?: boolean;
|
||||
isRunning?: boolean;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
children?: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function ToolUseBlock({
|
||||
toolName,
|
||||
toolInput: _toolInput,
|
||||
toolResult: _toolResult,
|
||||
isError = false,
|
||||
isRunning = false,
|
||||
startedAt,
|
||||
completedAt,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
}: ToolUseBlockProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded || isRunning);
|
||||
const startRef = useRef(startedAt ?? Date.now());
|
||||
|
||||
// Auto-expand while running, retain state after completion
|
||||
useEffect(() => {
|
||||
if (isRunning) setIsExpanded(true);
|
||||
}, [isRunning]);
|
||||
|
||||
const Icon = getToolIcon(toolName);
|
||||
const label = getToolLabel(toolName);
|
||||
|
||||
const borderColor = isRunning
|
||||
? "border-brand-600/40"
|
||||
: isError
|
||||
? "border-red-800/50"
|
||||
: "border-surface-700";
|
||||
|
||||
const headerBg = isRunning
|
||||
? "bg-brand-950/30"
|
||||
: isError
|
||||
? "bg-red-950/20"
|
||||
: "bg-surface-850";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border overflow-hidden text-sm",
|
||||
borderColor
|
||||
)}
|
||||
>
|
||||
{/* Header row */}
|
||||
<button
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2.5 text-left transition-colors",
|
||||
headerBg,
|
||||
"hover:bg-surface-800"
|
||||
)}
|
||||
>
|
||||
{/* Expand icon */}
|
||||
<span className="text-surface-500 flex-shrink-0">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Tool icon */}
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0",
|
||||
isRunning
|
||||
? "text-brand-400"
|
||||
: isError
|
||||
? "text-red-400"
|
||||
: "text-surface-400"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
|
||||
{/* Tool name */}
|
||||
<span className="text-surface-200 font-medium flex-1 truncate">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* Status */}
|
||||
<StatusBadge
|
||||
isRunning={isRunning}
|
||||
isError={isError}
|
||||
startedAt={startRef.current}
|
||||
completedAt={completedAt}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expandable body */}
|
||||
<AnimatePresence initial={false}>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
key="body"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: "easeInOut" }}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<div className="border-t border-surface-700 bg-surface-900">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
web/components/tools/ToolWebFetch.tsx
Normal file
125
web/components/tools/ToolWebFetch.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ExternalLink, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ToolUseBlock } from "./ToolUseBlock";
|
||||
|
||||
interface ToolWebFetchProps {
|
||||
input: {
|
||||
url: string;
|
||||
prompt?: string;
|
||||
};
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
isRunning?: boolean;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
// Very rough HTTP status parsing from result text
|
||||
function parseStatus(result: string): number | null {
|
||||
const m = result.match(/^(?:HTTP[^\n]*\s)?(\d{3})\b/m);
|
||||
if (m) return parseInt(m[1], 10);
|
||||
return null;
|
||||
}
|
||||
|
||||
function StatusBadge({ code }: { code: number | null }) {
|
||||
if (!code) return null;
|
||||
const isOk = code >= 200 && code < 300;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded font-mono",
|
||||
isOk
|
||||
? "bg-green-900/40 text-green-400 border border-green-800/40"
|
||||
: "bg-red-900/40 text-red-400 border border-red-800/40"
|
||||
)}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_VISIBLE = 80;
|
||||
|
||||
export function ToolWebFetch({
|
||||
input,
|
||||
result,
|
||||
isError = false,
|
||||
isRunning = false,
|
||||
startedAt,
|
||||
completedAt,
|
||||
}: ToolWebFetchProps) {
|
||||
const [showFull, setShowFull] = useState(false);
|
||||
|
||||
const status = result ? parseStatus(result) : null;
|
||||
const isTruncated = !showFull && result && result.length > MAX_VISIBLE * 10;
|
||||
|
||||
return (
|
||||
<ToolUseBlock
|
||||
toolName="webfetch"
|
||||
toolInput={input}
|
||||
toolResult={result}
|
||||
isError={isError}
|
||||
isRunning={isRunning}
|
||||
startedAt={startedAt}
|
||||
completedAt={completedAt}
|
||||
>
|
||||
{/* URL header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
|
||||
<a
|
||||
href={input.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="font-mono text-xs text-brand-400 hover:text-brand-300 hover:underline truncate flex-1 flex items-center gap-1"
|
||||
>
|
||||
{input.url}
|
||||
<ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||
</a>
|
||||
{status && <StatusBadge code={status} />}
|
||||
</div>
|
||||
|
||||
{/* Prompt if any */}
|
||||
{input.prompt && (
|
||||
<div className="px-3 py-2 border-b border-surface-700/50 text-xs text-surface-400 italic">
|
||||
Prompt: {input.prompt}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response body */}
|
||||
{isRunning ? (
|
||||
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
|
||||
Fetching…
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
|
||||
) : result ? (
|
||||
<div>
|
||||
<div className="overflow-auto max-h-[400px] px-3 py-3 text-xs text-surface-300 leading-relaxed whitespace-pre-wrap font-mono">
|
||||
{isTruncated ? result.slice(0, MAX_VISIBLE * 10) : result}
|
||||
</div>
|
||||
{isTruncated && (
|
||||
<button
|
||||
onClick={() => setShowFull(true)}
|
||||
className="flex items-center gap-1 mx-3 mb-2 text-xs text-brand-400 hover:text-brand-300"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
Show full response ({Math.round(result.length / 1024)}KB)
|
||||
</button>
|
||||
)}
|
||||
{showFull && (
|
||||
<button
|
||||
onClick={() => setShowFull(false)}
|
||||
className="flex items-center gap-1 mx-3 mb-2 text-xs text-surface-400 hover:text-surface-200"
|
||||
>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
Collapse
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</ToolUseBlock>
|
||||
);
|
||||
}
|
||||
116
web/components/tools/ToolWebSearch.tsx
Normal file
116
web/components/tools/ToolWebSearch.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, Search } from "lucide-react";
|
||||
import { ToolUseBlock } from "./ToolUseBlock";
|
||||
|
||||
interface SearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
interface ToolWebSearchProps {
|
||||
input: {
|
||||
query: string;
|
||||
};
|
||||
result?: string;
|
||||
isError?: boolean;
|
||||
isRunning?: boolean;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
function parseSearchResults(result: string): SearchResult[] {
|
||||
// Try JSON first
|
||||
try {
|
||||
const data = JSON.parse(result);
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => ({
|
||||
title: item.title ?? item.name ?? "(no title)",
|
||||
url: item.url ?? item.link ?? "",
|
||||
snippet: item.snippet ?? item.description ?? item.content ?? "",
|
||||
}));
|
||||
}
|
||||
if (data.results) return parseSearchResults(JSON.stringify(data.results));
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
|
||||
// Fallback: treat raw text as a single result
|
||||
return [{ title: "Search Result", url: "", snippet: result }];
|
||||
}
|
||||
|
||||
export function ToolWebSearch({
|
||||
input,
|
||||
result,
|
||||
isError = false,
|
||||
isRunning = false,
|
||||
startedAt,
|
||||
completedAt,
|
||||
}: ToolWebSearchProps) {
|
||||
const results = result && !isError ? parseSearchResults(result) : [];
|
||||
|
||||
return (
|
||||
<ToolUseBlock
|
||||
toolName="websearch"
|
||||
toolInput={input}
|
||||
toolResult={result}
|
||||
isError={isError}
|
||||
isRunning={isRunning}
|
||||
startedAt={startedAt}
|
||||
completedAt={completedAt}
|
||||
>
|
||||
{/* Query header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
|
||||
<Search className="w-3.5 h-3.5 text-surface-500 flex-shrink-0" />
|
||||
<span className="text-sm text-surface-200 flex-1">{input.query}</span>
|
||||
{!isRunning && results.length > 0 && (
|
||||
<span className="text-xs text-surface-500">
|
||||
{results.length} result{results.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{isRunning ? (
|
||||
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
|
||||
Searching…
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="px-3 py-3 text-surface-500 text-xs">No results.</div>
|
||||
) : (
|
||||
<div className="overflow-auto max-h-[480px] divide-y divide-surface-700/40">
|
||||
{results.map((r, i) => (
|
||||
<div key={i} className="px-3 py-3 hover:bg-surface-800/30 transition-colors">
|
||||
{r.url ? (
|
||||
<a
|
||||
href={r.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex flex-col gap-1"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium text-brand-400 group-hover:text-brand-300 group-hover:underline">
|
||||
{r.title}
|
||||
</span>
|
||||
<ExternalLink className="w-3 h-3 text-surface-500 flex-shrink-0" />
|
||||
</div>
|
||||
<span className="text-xs text-surface-500 truncate">{r.url}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-surface-200">{r.title}</span>
|
||||
)}
|
||||
{r.snippet && (
|
||||
<p className="mt-1 text-xs text-surface-400 leading-relaxed line-clamp-3">
|
||||
{r.snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ToolUseBlock>
|
||||
);
|
||||
}
|
||||
6
web/components/ui/ToastProvider.tsx
Normal file
6
web/components/ui/ToastProvider.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
"use client";
|
||||
|
||||
// Re-exports for backwards compatibility.
|
||||
// The notification system lives in web/components/notifications/ and web/lib/notifications.ts.
|
||||
export { ToastProvider } from "@/components/notifications/ToastProvider";
|
||||
export { useToast } from "@/hooks/useToast";
|
||||
82
web/components/ui/avatar.tsx
Normal file
82
web/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const avatarVariants = cva(
|
||||
'relative inline-flex items-center justify-center rounded-full overflow-hidden font-medium select-none flex-shrink-0',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'h-6 w-6 text-[10px]',
|
||||
sm: 'h-8 w-8 text-xs',
|
||||
md: 'h-10 w-10 text-sm',
|
||||
lg: 'h-12 w-12 text-base',
|
||||
xl: 'h-16 w-16 text-lg',
|
||||
},
|
||||
},
|
||||
defaultVariants: { size: 'md' },
|
||||
}
|
||||
)
|
||||
|
||||
export interface AvatarProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof avatarVariants> {
|
||||
src?: string
|
||||
alt?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((n) => n[0].toUpperCase())
|
||||
.join('')
|
||||
}
|
||||
|
||||
// Deterministic colour from name
|
||||
function getAvatarColor(name: string): string {
|
||||
const colours = [
|
||||
'bg-brand-700 text-brand-200',
|
||||
'bg-violet-800 text-violet-200',
|
||||
'bg-indigo-800 text-indigo-200',
|
||||
'bg-blue-800 text-blue-200',
|
||||
'bg-cyan-800 text-cyan-200',
|
||||
'bg-teal-800 text-teal-200',
|
||||
'bg-emerald-800 text-emerald-200',
|
||||
'bg-amber-800 text-amber-200',
|
||||
'bg-rose-800 text-rose-200',
|
||||
]
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = (hash * 31 + name.charCodeAt(i)) & 0xffffffff
|
||||
}
|
||||
return colours[Math.abs(hash) % colours.length]
|
||||
}
|
||||
|
||||
function Avatar({ className, size, src, alt, name, ...props }: AvatarProps) {
|
||||
const [imgError, setImgError] = React.useState(false)
|
||||
const showImage = src && !imgError
|
||||
const initials = name ? getInitials(name) : '?'
|
||||
const colorClass = name ? getAvatarColor(name) : 'bg-surface-700 text-surface-300'
|
||||
|
||||
return (
|
||||
<span className={cn(avatarVariants({ size, className }))} {...props}>
|
||||
{showImage ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? name ?? 'Avatar'}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className={cn('flex h-full w-full items-center justify-center', colorClass)} aria-label={name}>
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, avatarVariants }
|
||||
56
web/components/ui/badge.tsx
Normal file
56
web/components/ui/badge.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-surface-800 text-surface-300 border border-surface-700',
|
||||
success: 'bg-success-bg text-success border border-success/20',
|
||||
error: 'bg-error-bg text-error border border-error/20',
|
||||
warning: 'bg-warning-bg text-warning border border-warning/20',
|
||||
info: 'bg-info-bg text-info border border-info/20',
|
||||
brand: 'bg-brand-500/15 text-brand-300 border border-brand-500/25',
|
||||
outline: 'border border-surface-600 text-surface-400',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {
|
||||
dot?: boolean
|
||||
}
|
||||
|
||||
function Badge({ className, variant, dot = false, children, ...props }: BadgeProps) {
|
||||
const dotColors: Record<string, string> = {
|
||||
default: 'bg-surface-400',
|
||||
success: 'bg-success',
|
||||
error: 'bg-error',
|
||||
warning: 'bg-warning',
|
||||
info: 'bg-info',
|
||||
brand: 'bg-brand-400',
|
||||
outline: 'bg-surface-500',
|
||||
}
|
||||
const dotColor = dotColors[variant ?? 'default'] ?? dotColors.default
|
||||
|
||||
return (
|
||||
<span className={cn(badgeVariants({ variant, className }))} {...props}>
|
||||
{dot && (
|
||||
<span
|
||||
className={cn('h-1.5 w-1.5 rounded-full flex-shrink-0', dotColor)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
73
web/components/ui/button.tsx
Normal file
73
web/components/ui/button.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors duration-[var(--transition-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 select-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800',
|
||||
secondary: 'bg-surface-800 text-surface-100 border border-surface-700 hover:bg-surface-700',
|
||||
ghost: 'text-surface-400 hover:bg-surface-800 hover:text-surface-100',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800',
|
||||
// Legacy aliases
|
||||
default: 'bg-brand-600 text-white hover:bg-brand-700',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-surface-700 bg-transparent hover:bg-surface-800 text-surface-200',
|
||||
link: 'text-brand-400 underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-7 px-3 text-xs rounded',
|
||||
md: 'h-9 px-4 text-sm',
|
||||
lg: 'h-11 px-6 text-base',
|
||||
// Legacy aliases
|
||||
default: 'h-9 px-4 py-2',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, loading = false, children, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
const spinnerSize = size === 'sm' ? 12 : size === 'lg' ? 18 : 14
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<Loader2
|
||||
className="animate-spin flex-shrink-0"
|
||||
size={spinnerSize}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
131
web/components/ui/dialog.tsx
Normal file
131
web/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixDialog from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const dialogContentVariants = cva(
|
||||
[
|
||||
'relative z-50 grid w-full gap-4 rounded-lg border border-surface-700',
|
||||
'bg-surface-900 p-6 shadow-lg',
|
||||
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
|
||||
].join(' '),
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
full: 'max-w-[calc(100vw-2rem)] h-[calc(100vh-4rem)]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Dialog = RadixDialog.Root
|
||||
const DialogTrigger = RadixDialog.Trigger
|
||||
const DialogPortal = RadixDialog.Portal
|
||||
const DialogClose = RadixDialog.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDialog.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDialog.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDialog.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = RadixDialog.Overlay.displayName
|
||||
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof RadixDialog.Content>,
|
||||
VariantProps<typeof dialogContentVariants> {
|
||||
showClose?: boolean
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDialog.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, children, size, showClose = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<RadixDialog.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
||||
dialogContentVariants({ size, className })
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showClose && (
|
||||
<DialogClose className="absolute right-4 top-4 rounded-sm text-surface-500 hover:text-surface-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
)}
|
||||
</RadixDialog.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = RadixDialog.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col gap-1.5', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDialog.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDialog.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDialog.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-surface-50', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = RadixDialog.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDialog.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDialog.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDialog.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-surface-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = RadixDialog.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
191
web/components/ui/dropdown-menu.tsx
Normal file
191
web/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const DropdownMenu = RadixDropdownMenu.Root
|
||||
const DropdownMenuTrigger = RadixDropdownMenu.Trigger
|
||||
const DropdownMenuGroup = RadixDropdownMenu.Group
|
||||
const DropdownMenuPortal = RadixDropdownMenu.Portal
|
||||
const DropdownMenuSub = RadixDropdownMenu.Sub
|
||||
const DropdownMenuRadioGroup = RadixDropdownMenu.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.SubTrigger> & { inset?: boolean }
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<RadixDropdownMenu.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm',
|
||||
'text-surface-200 outline-none focus:bg-surface-800 data-[state=open]:bg-surface-800',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4 text-surface-500" aria-hidden="true" />
|
||||
</RadixDropdownMenu.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = RadixDropdownMenu.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDropdownMenu.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
|
||||
'bg-surface-850 p-1 text-surface-100 shadow-lg',
|
||||
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = RadixDropdownMenu.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<RadixDropdownMenu.Portal>
|
||||
<RadixDropdownMenu.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
|
||||
'bg-surface-850 p-1 text-surface-100 shadow-lg',
|
||||
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</RadixDropdownMenu.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = RadixDropdownMenu.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Item> & { inset?: boolean; destructive?: boolean }
|
||||
>(({ className, inset, destructive, ...props }, ref) => (
|
||||
<RadixDropdownMenu.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm',
|
||||
'outline-none transition-colors focus:bg-surface-800',
|
||||
destructive
|
||||
? 'text-red-400 focus:text-red-300'
|
||||
: 'text-surface-200 focus:text-surface-50',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = RadixDropdownMenu.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<RadixDropdownMenu.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
|
||||
'text-surface-200 outline-none transition-colors focus:bg-surface-800 focus:text-surface-50',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<RadixDropdownMenu.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-brand-400" aria-hidden="true" />
|
||||
</RadixDropdownMenu.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</RadixDropdownMenu.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = RadixDropdownMenu.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<RadixDropdownMenu.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
|
||||
'text-surface-200 outline-none transition-colors focus:bg-surface-800 focus:text-surface-50',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<RadixDropdownMenu.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-brand-400 text-brand-400" aria-hidden="true" />
|
||||
</RadixDropdownMenu.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</RadixDropdownMenu.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = RadixDropdownMenu.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Label> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<RadixDropdownMenu.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-xs font-semibold text-surface-500 uppercase tracking-wider',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = RadixDropdownMenu.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof RadixDropdownMenu.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixDropdownMenu.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-surface-700', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = RadixDropdownMenu.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn('ml-auto text-xs tracking-widest text-surface-500', className)} {...props} />
|
||||
)
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
79
web/components/ui/input.tsx
Normal file
79
web/components/ui/input.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helper?: string
|
||||
variant?: 'default' | 'search'
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, label, error, helper, variant = 'default', id, ...props }, ref) => {
|
||||
const inputId = id ?? React.useId()
|
||||
const errorId = `${inputId}-error`
|
||||
const helperId = `${inputId}-helper`
|
||||
|
||||
const describedBy = [
|
||||
error ? errorId : null,
|
||||
helper ? helperId : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="text-sm font-medium text-surface-200"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{variant === 'search' && (
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-surface-500 pointer-events-none"
|
||||
size={15}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
ref={ref}
|
||||
aria-describedby={describedBy || undefined}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border bg-surface-900 px-3 py-1 text-sm text-surface-100',
|
||||
'border-surface-700 placeholder:text-surface-500',
|
||||
'transition-colors duration-[var(--transition-fast)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variant === 'search' && 'pl-9',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={errorId} className="text-xs text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helper && !error && (
|
||||
<p id={helperId} className="text-xs text-surface-500">
|
||||
{helper}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
161
web/components/ui/select.tsx
Normal file
161
web/components/ui/select.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixSelect from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = RadixSelect.Root
|
||||
const SelectGroup = RadixSelect.Group
|
||||
const SelectValue = RadixSelect.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<RadixSelect.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-md border border-surface-700',
|
||||
'bg-surface-900 px-3 py-2 text-sm text-surface-100',
|
||||
'placeholder:text-surface-500',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<RadixSelect.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-surface-500 flex-shrink-0" aria-hidden="true" />
|
||||
</RadixSelect.Icon>
|
||||
</RadixSelect.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = RadixSelect.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixSelect.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 text-surface-500" aria-hidden="true" />
|
||||
</RadixSelect.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = RadixSelect.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixSelect.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 text-surface-500" aria-hidden="true" />
|
||||
</RadixSelect.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = RadixSelect.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<RadixSelect.Portal>
|
||||
<RadixSelect.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
|
||||
'bg-surface-850 text-surface-100 shadow-lg',
|
||||
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
|
||||
position === 'popper' && [
|
||||
'data-[side=bottom]:translate-y-1',
|
||||
'data-[side=top]:-translate-y-1',
|
||||
],
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<RadixSelect.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</RadixSelect.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</RadixSelect.Content>
|
||||
</RadixSelect.Portal>
|
||||
))
|
||||
SelectContent.displayName = RadixSelect.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixSelect.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-semibold text-surface-500 uppercase tracking-wider', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = RadixSelect.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<RadixSelect.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
|
||||
'text-surface-200 outline-none',
|
||||
'focus:bg-surface-800 focus:text-surface-50',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<RadixSelect.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-brand-400" aria-hidden="true" />
|
||||
</RadixSelect.ItemIndicator>
|
||||
</span>
|
||||
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
|
||||
</RadixSelect.Item>
|
||||
))
|
||||
SelectItem.displayName = RadixSelect.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof RadixSelect.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixSelect.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixSelect.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-surface-700', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = RadixSelect.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
64
web/components/ui/tabs.tsx
Normal file
64
web/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixTabs from '@radix-ui/react-tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = RadixTabs.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof RadixTabs.List>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixTabs.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixTabs.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative inline-flex items-center border-b border-surface-800 w-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = RadixTabs.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof RadixTabs.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixTabs.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixTabs.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap',
|
||||
'px-4 py-2.5 text-sm font-medium',
|
||||
'text-surface-500 transition-colors duration-[var(--transition-fast)]',
|
||||
'hover:text-surface-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-inset',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
// Animated underline via pseudo-element
|
||||
'after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full',
|
||||
'after:scale-x-0 after:bg-brand-500 after:transition-transform after:duration-[var(--transition-normal)]',
|
||||
'data-[state=active]:text-surface-50 data-[state=active]:after:scale-x-100',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = RadixTabs.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixTabs.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixTabs.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<RadixTabs.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'data-[state=active]:animate-fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = RadixTabs.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
105
web/components/ui/textarea.tsx
Normal file
105
web/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helper?: string
|
||||
maxCount?: number
|
||||
autoGrow?: boolean
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, label, error, helper, maxCount, autoGrow = false, id, onChange, value, ...props }, ref) => {
|
||||
const textareaId = id ?? React.useId()
|
||||
const errorId = `${textareaId}-error`
|
||||
const helperId = `${textareaId}-helper`
|
||||
const internalRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
const resolvedRef = (ref as React.RefObject<HTMLTextAreaElement>) ?? internalRef
|
||||
|
||||
const [charCount, setCharCount] = React.useState(
|
||||
typeof value === 'string' ? value.length : 0
|
||||
)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setCharCount(e.target.value.length)
|
||||
if (autoGrow && resolvedRef.current) {
|
||||
resolvedRef.current.style.height = 'auto'
|
||||
resolvedRef.current.style.height = `${resolvedRef.current.scrollHeight}px`
|
||||
}
|
||||
onChange?.(e)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoGrow && resolvedRef.current) {
|
||||
resolvedRef.current.style.height = 'auto'
|
||||
resolvedRef.current.style.height = `${resolvedRef.current.scrollHeight}px`
|
||||
}
|
||||
}, [value, autoGrow, resolvedRef])
|
||||
|
||||
const describedBy = [error ? errorId : null, helper ? helperId : null]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const isOverLimit = maxCount !== undefined && charCount > maxCount
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label && (
|
||||
<label htmlFor={textareaId} className="text-sm font-medium text-surface-200">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
id={textareaId}
|
||||
ref={resolvedRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-describedby={describedBy || undefined}
|
||||
aria-invalid={error ? true : undefined}
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border bg-surface-900 px-3 py-2 text-sm text-surface-100',
|
||||
'border-surface-700 placeholder:text-surface-500',
|
||||
'transition-colors duration-[var(--transition-fast)] resize-none',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
autoGrow && 'overflow-hidden',
|
||||
error && 'border-red-500 focus-visible:ring-red-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
{error && (
|
||||
<p id={errorId} className="text-xs text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helper && !error && (
|
||||
<p id={helperId} className="text-xs text-surface-500">
|
||||
{helper}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{maxCount !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs tabular-nums ml-auto flex-shrink-0',
|
||||
isOverLimit ? 'text-red-400' : 'text-surface-500'
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{charCount}/{maxCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
168
web/components/ui/toast.tsx
Normal file
168
web/components/ui/toast.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixToast from '@radix-ui/react-toast'
|
||||
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
export interface ToastData {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
variant?: ToastVariant
|
||||
duration?: number
|
||||
}
|
||||
|
||||
// ── Store (singleton for imperative toasts) ───────────────────────────────────
|
||||
|
||||
type Listener = (toasts: ToastData[]) => void
|
||||
|
||||
let toastList: ToastData[] = []
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((fn) => fn([...toastList]))
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
show(data: Omit<ToastData, 'id'>) {
|
||||
const id = Math.random().toString(36).slice(2, 9)
|
||||
toastList = [...toastList, { id, ...data }]
|
||||
emit()
|
||||
return id
|
||||
},
|
||||
success(title: string, description?: string) {
|
||||
return this.show({ title, description, variant: 'success' })
|
||||
},
|
||||
error(title: string, description?: string) {
|
||||
return this.show({ title, description, variant: 'error' })
|
||||
},
|
||||
warning(title: string, description?: string) {
|
||||
return this.show({ title, description, variant: 'warning' })
|
||||
},
|
||||
info(title: string, description?: string) {
|
||||
return this.show({ title, description, variant: 'info' })
|
||||
},
|
||||
dismiss(id: string) {
|
||||
toastList = toastList.filter((t) => t.id !== id)
|
||||
emit()
|
||||
},
|
||||
}
|
||||
|
||||
function useToastStore() {
|
||||
const [toasts, setToasts] = React.useState<ToastData[]>([])
|
||||
React.useEffect(() => {
|
||||
setToasts([...toastList])
|
||||
listeners.add(setToasts)
|
||||
return () => { listeners.delete(setToasts) }
|
||||
}, [])
|
||||
return toasts
|
||||
}
|
||||
|
||||
// ── Style variants ────────────────────────────────────────────────────────────
|
||||
|
||||
const toastVariants = cva(
|
||||
[
|
||||
'group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden',
|
||||
'rounded-lg border p-4 shadow-lg transition-all',
|
||||
'data-[state=open]:animate-slide-up data-[state=closed]:animate-slide-down-out',
|
||||
'data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]',
|
||||
'data-[swipe=cancel]:translate-x-0',
|
||||
'data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=end]:animate-fade-out',
|
||||
].join(' '),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-surface-800 border-surface-700 text-surface-100',
|
||||
success: 'bg-surface-800 border-green-800/60 text-surface-100',
|
||||
error: 'bg-surface-800 border-red-800/60 text-surface-100',
|
||||
warning: 'bg-surface-800 border-yellow-800/60 text-surface-100',
|
||||
info: 'bg-surface-800 border-blue-800/60 text-surface-100',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
}
|
||||
)
|
||||
|
||||
const variantIcons: Record<ToastVariant, React.ReactNode> = {
|
||||
default: null,
|
||||
success: <CheckCircle2 className="h-4 w-4 text-green-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
|
||||
error: <AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
|
||||
warning: <AlertTriangle className="h-4 w-4 text-yellow-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
|
||||
info: <Info className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
|
||||
}
|
||||
|
||||
// ── Single toast item ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ToastItemProps extends VariantProps<typeof toastVariants> {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
function ToastItem({ id, title, description, variant = 'default', duration = 5000 }: ToastItemProps) {
|
||||
const [open, setOpen] = React.useState(true)
|
||||
const icon = variantIcons[variant ?? 'default']
|
||||
|
||||
return (
|
||||
<RadixToast.Root
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
setOpen(o)
|
||||
if (!o) setTimeout(() => toast.dismiss(id), 300)
|
||||
}}
|
||||
duration={duration}
|
||||
className={cn(toastVariants({ variant }))}
|
||||
>
|
||||
{icon}
|
||||
<div className="flex-1 min-w-0">
|
||||
<RadixToast.Title className="text-sm font-medium leading-snug">{title}</RadixToast.Title>
|
||||
{description && (
|
||||
<RadixToast.Description className="mt-0.5 text-xs text-surface-400 leading-relaxed">
|
||||
{description}
|
||||
</RadixToast.Description>
|
||||
)}
|
||||
</div>
|
||||
<RadixToast.Close
|
||||
className="flex-shrink-0 text-surface-500 hover:text-surface-200 transition-colors rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</RadixToast.Close>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-0.5 w-full origin-left bg-current opacity-20"
|
||||
style={{ animation: `progress ${duration}ms linear forwards` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</RadixToast.Root>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Provider (mount once in layout) ──────────────────────────────────────────
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const toasts = useToastStore()
|
||||
|
||||
return (
|
||||
<RadixToast.Provider swipeDirection="right">
|
||||
{children}
|
||||
{toasts.map((t) => (
|
||||
<ToastItem key={t.id} {...t} />
|
||||
))}
|
||||
<RadixToast.Viewport className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-80 focus:outline-none" />
|
||||
</RadixToast.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hook (alternative to imperative API) ─────────────────────────────────────
|
||||
|
||||
export function useToast() {
|
||||
return toast
|
||||
}
|
||||
57
web/components/ui/tooltip.tsx
Normal file
57
web/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as RadixTooltip from '@radix-ui/react-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = RadixTooltip.Provider
|
||||
const Tooltip = RadixTooltip.Root
|
||||
const TooltipTrigger = RadixTooltip.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof RadixTooltip.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof RadixTooltip.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<RadixTooltip.Portal>
|
||||
<RadixTooltip.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md border border-surface-700',
|
||||
'bg-surface-800 px-3 py-1.5 text-xs text-surface-100 shadow-md',
|
||||
'animate-scale-in data-[state=closed]:animate-scale-out',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</RadixTooltip.Portal>
|
||||
))
|
||||
TooltipContent.displayName = RadixTooltip.Content.displayName
|
||||
|
||||
// Convenience wrapper
|
||||
interface SimpleTooltipProps {
|
||||
content: React.ReactNode
|
||||
children: React.ReactNode
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
delayDuration?: number
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
function SimpleTooltip({
|
||||
content,
|
||||
children,
|
||||
side = 'top',
|
||||
delayDuration = 400,
|
||||
asChild = false,
|
||||
}: SimpleTooltipProps) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={delayDuration}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={asChild}>{children}</TooltipTrigger>
|
||||
<TooltipContent side={side}>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, SimpleTooltip }
|
||||
Reference in New Issue
Block a user