-
+
+
+
+
+
+
Buddy helper
+
{helperSummary}
+
+
onOpenBuddy?.()}
+ className="inline-flex items-center gap-2 rounded-md border border-surface-700 px-2.5 py-1 text-xs text-surface-300 transition-colors hover:bg-surface-800 hover:text-surface-100"
+ disabled={!onOpenBuddy}
+ >
+
+ Buddy panel
+
+
+
+ {(buddySuggestions ?? []).map((suggestion) => (
+ {
+ setInput(suggestion.prompt);
+ requestAnimationFrame(() => {
+ textareaRef.current?.focus();
+ adjustHeight();
+ });
+ }}
+ className="rounded-full border border-surface-700 px-3 py-1 text-xs text-surface-300 transition-colors hover:border-brand-500 hover:bg-surface-800 hover:text-surface-100"
+ >
+ {suggestion.label}
+
+ ))}
+
+
+
-
+
@@ -135,13 +233,12 @@ export function ChatInput({ conversationId }: ChatInputProps) {
adjustHeight();
}}
onKeyDown={handleKeyDown}
- placeholder="Message Claude Code..."
- rows={1}
+ placeholder="Message AG-Claw..."
+ rows={Math.max(1, estimatedRows)}
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"
+ "min-h-[24px] max-h-[200px] flex-1 resize-none bg-transparent py-0.5 text-sm text-surface-100",
+ "placeholder:text-surface-500 focus:outline-none"
)}
/>
@@ -149,30 +246,29 @@ export function ChatInput({ conversationId }: ChatInputProps) {
-
+
) : (
-
+
)}
-
- Claude can make mistakes. Verify important information.
+
+ AG-Claw can make mistakes. Verify important information.
diff --git a/web/components/chat/ChatLayout.tsx b/web/components/chat/ChatLayout.tsx
index 8b3c903b..45630c2e 100644
--- a/web/components/chat/ChatLayout.tsx
+++ b/web/components/chat/ChatLayout.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useChatStore } from "@/lib/store";
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
@@ -8,41 +8,124 @@ import { ChatWindow } from "./ChatWindow";
import { ChatInput } from "./ChatInput";
import { SkipToContent } from "@/components/a11y/SkipToContent";
import { AnnouncerProvider } from "@/components/a11y/Announcer";
+import { DesktopFileViewer } from "@/components/file-viewer/DesktopFileViewer";
+import { SettingsDialog } from "@/components/settings/SettingsDialog";
+import { CollaborationProvider } from "@/components/collaboration/CollaborationProvider";
+import { ResearchWorkbench } from "@/components/research/ResearchWorkbench";
+import { BuddyPanel } from "@/components/buddy/BuddyPanel";
+import { getBuddyProfile, getBuddySeed, getBuddySuggestions, type BuddyProfile } from "@/lib/buddy";
+
+const DEMO_USER = {
+ id: "local-user",
+ name: "Local Operator",
+ email: "local@example.com",
+ color: "#22c55e",
+ role: "owner" as const,
+};
+
+function extractMessageText(content: string | Array<{ type?: string; text?: string; content?: unknown }>) {
+ if (typeof content === "string") {
+ return content;
+ }
+ return content
+ .map((block) => {
+ if (block?.type === "text") {
+ return block.text ?? "";
+ }
+ if (block?.type === "tool_result" && typeof block.content === "string") {
+ return block.content;
+ }
+ return "";
+ })
+ .join("");
+}
export function ChatLayout() {
- const { conversations, createConversation, activeConversationId } = useChatStore();
+ const {
+ conversations,
+ createConversation,
+ activeConversationId,
+ buddyOpen,
+ openBuddy,
+ closeBuddy,
+ } = useChatStore();
+ const [buddyProfile, setBuddyProfile] = useState
(() => getBuddyProfile("agclaw-local-operator"));
+ const [pendingBuddyPrompt, setPendingBuddyPrompt] = useState("");
useEffect(() => {
if (conversations.length === 0) {
createConversation();
}
+ }, [conversations.length, createConversation]);
+
+ useEffect(() => {
+ setBuddyProfile(getBuddyProfile(getBuddySeed()));
}, []);
+ const activeConversation = useMemo(
+ () => conversations.find((conversation) => conversation.id === activeConversationId) ?? null,
+ [conversations, activeConversationId]
+ );
+
+ const latestPrompt = useMemo(() => {
+ const latestUserMessage = [...(activeConversation?.messages ?? [])]
+ .reverse()
+ .find((message) => message.role === "user");
+ return latestUserMessage ? extractMessageText(latestUserMessage.content) : "";
+ }, [activeConversation]);
+
+ const buddySuggestions = useMemo(
+ () => getBuddySuggestions(buddyProfile, latestPrompt),
+ [buddyProfile, latestPrompt]
+ );
+
return (
-
-
-
-
-
-
-
- {activeConversationId ? (
- <>
-
-
- >
- ) : (
-
- Select or create a conversation
+
+
+
+
+
+
+
+
+
+ {activeConversationId ? (
+ <>
+
+
setPendingBuddyPrompt("")}
+ />
+ >
+ ) : (
+
+ Select or create a conversation
+
+ )}
- )}
-
+
+
+
-
-
+
+
+ {
+ setPendingBuddyPrompt(prompt);
+ closeBuddy();
+ }}
+ />
+
+
);
}
diff --git a/web/components/chat/ChatWindow.tsx b/web/components/chat/ChatWindow.tsx
index fc7c841f..adf126fb 100644
--- a/web/components/chat/ChatWindow.tsx
+++ b/web/components/chat/ChatWindow.tsx
@@ -1,6 +1,6 @@
-"use client";
+"use client";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useChatStore } from "@/lib/store";
import { MessageBubble } from "./MessageBubble";
import { Bot } from "lucide-react";
@@ -13,7 +13,7 @@ export function ChatWindow({ conversationId }: ChatWindowProps) {
const bottomRef = useRef(null);
const { conversations } = useChatStore();
const conversation = conversations.find((c) => c.id === conversationId);
- const messages = conversation?.messages ?? [];
+ const messages = useMemo(() => conversation?.messages ?? [], [conversation]);
const isStreaming = messages.some((m) => m.status === "streaming");
@@ -33,7 +33,7 @@ export function ChatWindow({ conversationId }: ChatWindowProps) {
// 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);
+ setTimeout(() => setAnnouncement(`AG-Claw replied: ${preview}`), 50);
}
prevLengthRef.current = messages.length;
}, [messages.length, messages]);
@@ -50,7 +50,7 @@ export function ChatWindow({ conversationId }: ChatWindowProps) {
How can I help?
- Start a conversation with Claude Code
+ Start a conversation with AG-Claw
@@ -60,25 +60,14 @@ export function ChatWindow({ conversationId }: ChatWindowProps) {
return (
- {/* Polite live region — announces when Claude finishes a reply */}
+ {/* Polite live region announces when AG-Claw finishes a reply. */}
{announcement}
@@ -92,3 +81,6 @@ export function ChatWindow({ conversationId }: ChatWindowProps) {
);
}
+
+
+
diff --git a/web/components/chat/MessageBubble.tsx b/web/components/chat/MessageBubble.tsx
index f4651e5a..123193a4 100644
--- a/web/components/chat/MessageBubble.tsx
+++ b/web/components/chat/MessageBubble.tsx
@@ -1,9 +1,10 @@
-"use client";
+"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";
+import { AnnotationBadge } from "@/components/collaboration/AnnotationBadge";
interface MessageBubbleProps {
message: Message;
@@ -20,9 +21,9 @@ export function MessageBubble({ message }: MessageBubbleProps) {
"flex gap-3 animate-fade-in",
isUser && "flex-row-reverse"
)}
- aria-label={isUser ? "You" : isError ? "Error from Claude" : "Claude"}
+ aria-label={isUser ? "You" : isError ? "Error from AG-Claw" : "AG-Claw"}
>
- {/* Avatar — purely decorative, role conveyed by article label */}
+ {/* Avatar — purely decorative, role conveyed by article label */}
)}
+ {!isUser && (
+
+ )}
);
}
+
+
diff --git a/web/components/collaboration/AnnotationBadge.tsx b/web/components/collaboration/AnnotationBadge.tsx
index 648753cd..77add60d 100644
--- a/web/components/collaboration/AnnotationBadge.tsx
+++ b/web/components/collaboration/AnnotationBadge.tsx
@@ -18,7 +18,6 @@ export function AnnotationBadge({ messageId }: AnnotationBadgeProps) {
const annotations = ctx.annotations[messageId] ?? [];
const unresolved = annotations.filter((a) => !a.resolved);
- if (annotations.length === 0) return null;
return (
@@ -31,10 +30,14 @@ export function AnnotationBadge({ messageId }: AnnotationBadgeProps) {
? "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" : ""}`}
+ title={
+ annotations.length === 0
+ ? "Add comment"
+ : `${annotations.length} comment${annotations.length !== 1 ? "s" : ""}`
+ }
>
- {unresolved.length > 0 ? unresolved.length : annotations.length}
+ {annotations.length === 0 ? "Comment" : unresolved.length > 0 ? unresolved.length : annotations.length}
{open && (
diff --git a/web/components/collaboration/AnnotationThread.tsx b/web/components/collaboration/AnnotationThread.tsx
new file mode 100644
index 00000000..edb55cca
--- /dev/null
+++ b/web/components/collaboration/AnnotationThread.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { Check, MessageSquareText, X } from "lucide-react";
+import { useCollaborationContext } from "./CollaborationProvider";
+import { measureCommentPreview } from "@/lib/pretextSpike";
+import { cn } from "@/lib/utils";
+
+interface AnnotationThreadProps {
+ messageId: string;
+ onClose: () => void;
+}
+
+function formatTimestamp(value: number) {
+ return new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(value);
+}
+
+export function AnnotationThread({ messageId, onClose }: AnnotationThreadProps) {
+ const { annotations, addAnnotation, resolveAnnotation, replyAnnotation } = useCollaborationContext();
+ const [draft, setDraft] = useState("");
+ const [replyDrafts, setReplyDrafts] = useState
>({});
+
+ const thread = useMemo(() => annotations[messageId] ?? [], [annotations, messageId]);
+
+ return (
+
+
+
+
+ {thread.map((annotation) => (
+
+
+
+
{annotation.author.name}
+
{formatTimestamp(annotation.createdAt)}
+
+
resolveAnnotation(annotation.id, !annotation.resolved)}
+ className={cn(
+ "inline-flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors",
+ annotation.resolved
+ ? "bg-emerald-900/30 text-emerald-300 hover:bg-emerald-900/50"
+ : "bg-surface-800 text-surface-300 hover:bg-surface-700"
+ )}
+ >
+
+ {annotation.resolved ? "Resolved" : "Resolve"}
+
+
+
+ {annotation.text}
+
+
+ {annotation.replies.length > 0 && (
+
+ {annotation.replies.map((reply) => (
+
+
{reply.author.name}
+
{formatTimestamp(reply.createdAt)}
+
{reply.text}
+
+ ))}
+
+ )}
+
+
+
+ setReplyDrafts((current) => ({
+ ...current,
+ [annotation.id]: e.target.value,
+ }))
+ }
+ placeholder="Reply"
+ className="min-w-0 flex-1 rounded-md border border-surface-700 bg-surface-950 px-3 py-2 text-sm text-surface-100 outline-none placeholder:text-surface-500 focus:border-brand-500"
+ />
+ {
+ const value = (replyDrafts[annotation.id] ?? "").trim();
+ if (!value) return;
+ replyAnnotation(annotation.id, value);
+ setReplyDrafts((current) => ({ ...current, [annotation.id]: "" }));
+ }}
+ className="rounded-md bg-surface-800 px-3 py-2 text-sm text-surface-200 transition-colors hover:bg-surface-700"
+ >
+ Send
+
+
+
+ ))}
+
+ {thread.length === 0 && (
+
+ No comments yet for this message.
+
+ )}
+
+
+
+
+ setDraft(e.target.value)}
+ placeholder="Add a comment"
+ className="min-w-0 flex-1 rounded-md border border-surface-700 bg-surface-950 px-3 py-2 text-sm text-surface-100 outline-none placeholder:text-surface-500 focus:border-brand-500"
+ />
+ {
+ const value = draft.trim();
+ if (!value) return;
+ addAnnotation(messageId, value);
+ setDraft("");
+ }}
+ className="rounded-md bg-brand-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-500"
+ >
+ Add
+
+
+
+
+ );
+}
diff --git a/web/components/collaboration/CursorGhost.tsx b/web/components/collaboration/CursorGhost.tsx
index cc730523..4c642916 100644
--- a/web/components/collaboration/CursorGhost.tsx
+++ b/web/components/collaboration/CursorGhost.tsx
@@ -85,7 +85,7 @@ export function CursorGhost({ textareaRef }: CursorGhostProps) {
}
}
setRendered(next);
- });
+ }, [ctx, textareaRef]);
if (!ctx || rendered.length === 0) return null;
diff --git a/web/components/file-viewer/DesktopFileViewer.tsx b/web/components/file-viewer/DesktopFileViewer.tsx
new file mode 100644
index 00000000..a9d40d79
--- /dev/null
+++ b/web/components/file-viewer/DesktopFileViewer.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import { X, Save } from "lucide-react";
+import { useFileViewerStore } from "@/lib/fileViewerStore";
+import { truncateMeasuredText } from "@/lib/pretextSpike";
+import { cn } from "@/lib/utils";
+import { FileBreadcrumb } from "./FileBreadcrumb";
+import { FileInfoBar } from "./FileInfoBar";
+import { ImageViewer } from "./ImageViewer";
+
+export function DesktopFileViewer() {
+ const {
+ isOpen,
+ tabs,
+ activeTabId,
+ closeTab,
+ updateContent,
+ markSaved,
+ } = useFileViewerStore();
+ const [isSaving, setIsSaving] = useState(false);
+
+ const activeTab = useMemo(
+ () => tabs.find((tab) => tab.id === activeTabId) ?? null,
+ [tabs, activeTabId]
+ );
+ const measuredTitle = useMemo(
+ () => (activeTab ? truncateMeasuredText(activeTab.path, 360) : null),
+ [activeTab]
+ );
+
+ if (!isOpen || !activeTab) {
+ return null;
+ }
+
+ const handleSave = async () => {
+ if (activeTab.isImage || activeTab.mode === "diff") return;
+ setIsSaving(true);
+ try {
+ const response = await fetch("/api/files/write", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ path: activeTab.path, content: activeTab.content }),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ markSaved(activeTab.id);
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+
{activeTab.filename}
+
+ {measuredTitle?.truncated ?? "Workspace file viewer"}
+
+
+
+ {!activeTab.isImage && activeTab.mode !== "diff" && (
+
+
+ {isSaving ? "Saving..." : "Save"}
+
+ )}
+ closeTab(activeTab.id)}
+ className="rounded-md p-1.5 text-surface-500 transition-colors hover:bg-surface-800 hover:text-surface-200"
+ aria-label="Close file viewer"
+ >
+
+
+
+
+
+
+
+
+ {activeTab.isImage ? (
+
+ ) : activeTab.mode === "diff" && activeTab.diff ? (
+
+
{activeTab.diff.oldContent}
+
{activeTab.diff.newContent}
+
+ ) : activeTab.mode === "edit" ? (
+
+
+
+
+
+ );
+}
diff --git a/web/components/file-viewer/SearchBar.tsx b/web/components/file-viewer/SearchBar.tsx
index 96dd9ddc..d5829e2c 100644
--- a/web/components/file-viewer/SearchBar.tsx
+++ b/web/components/file-viewer/SearchBar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, useCallback } from "react";
import { X, ChevronUp, ChevronDown, Regex, CaseSensitive } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -19,6 +19,16 @@ export function SearchBar({ content, containerRef, onClose }: SearchBarProps) {
const [hasError, setHasError] = useState(false);
const inputRef = useRef(null);
+ const clearHighlights = useCallback(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ const marks = container.querySelectorAll("mark.search-highlight");
+ marks.forEach((mark) => {
+ mark.replaceWith(mark.textContent ?? "");
+ });
+ container.normalize();
+ }, [containerRef]);
+
useEffect(() => {
inputRef.current?.focus();
}, []);
@@ -44,11 +54,12 @@ export function SearchBar({ content, containerRef, onClose }: SearchBarProps) {
setTotalMatches(0);
setCurrentMatch(0);
}
- }, [query, isRegex, caseSensitive, content]);
+ }, [query, isRegex, caseSensitive, content, clearHighlights]);
// Apply DOM highlights
useEffect(() => {
- if (!containerRef.current) return;
+ const container = containerRef.current;
+ if (!container) return;
clearHighlights();
if (!query || hasError || totalMatches === 0) return;
@@ -58,7 +69,7 @@ export function SearchBar({ content, containerRef, onClose }: SearchBarProps) {
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
const walker = document.createTreeWalker(
- containerRef.current,
+ container,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
@@ -121,27 +132,16 @@ export function SearchBar({ content, containerRef, onClose }: SearchBarProps) {
}
// Scroll current match into view
- const currentEl = containerRef.current?.querySelector(".search-highlight-current");
+ const currentEl = container.querySelector(".search-highlight-current");
currentEl?.scrollIntoView({ block: "center", behavior: "smooth" });
} catch {
// Ignore DOM errors
}
return () => {
- if (containerRef.current) clearHighlights();
+ 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();
- }
+ }, [query, isRegex, caseSensitive, currentMatch, totalMatches, hasError, clearHighlights, containerRef]);
const goNext = () => {
setCurrentMatch((c) => (c >= totalMatches ? 1 : c + 1));
diff --git a/web/components/layout/ChatHistory.tsx b/web/components/layout/ChatHistory.tsx
new file mode 100644
index 00000000..395fa4f8
--- /dev/null
+++ b/web/components/layout/ChatHistory.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { MessageSquarePlus, Pin } from "lucide-react";
+import { useChatStore } from "@/lib/store";
+import { cn } from "@/lib/utils";
+
+function formatDate(timestamp: number) {
+ return new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "numeric",
+ }).format(timestamp);
+}
+
+export function ChatHistory() {
+ const {
+ conversations,
+ activeConversationId,
+ pinnedIds,
+ searchQuery,
+ createConversation,
+ setActiveConversation,
+ pinConversation,
+ setSearchQuery,
+ } = useChatStore();
+
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+ const filtered = conversations.filter((conversation) => {
+ if (!normalizedQuery) return true;
+ return conversation.title.toLowerCase().includes(normalizedQuery);
+ });
+
+ return (
+
+
+
+
+ New conversation
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search conversations"
+ className="w-full rounded-md border border-surface-700 bg-surface-900 px-3 py-2 text-sm text-surface-100 outline-none transition-colors placeholder:text-surface-500 focus:border-brand-500"
+ />
+
+
+
+ {filtered.length === 0 ? (
+
+ No conversations match the current filter.
+
+ ) : (
+
+ {filtered.map((conversation) => {
+ const isPinned = pinnedIds.includes(conversation.id);
+ const isActive = conversation.id === activeConversationId;
+ return (
+
+
setActiveConversation(conversation.id)}
+ className="min-w-0 flex-1 text-left"
+ >
+
+ {conversation.title}
+
+
+ {conversation.messages.length} messages
+ {formatDate(conversation.updatedAt)}
+
+
+
pinConversation(conversation.id)}
+ aria-label={isPinned ? "Unpin conversation" : "Pin conversation"}
+ className={cn(
+ "rounded p-1 text-surface-500 transition-colors hover:bg-surface-800 hover:text-surface-200",
+ isPinned && "text-amber-300"
+ )}
+ >
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/web/components/layout/FileExplorer.tsx b/web/components/layout/FileExplorer.tsx
new file mode 100644
index 00000000..2ba4a76d
--- /dev/null
+++ b/web/components/layout/FileExplorer.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { ChevronDown, ChevronRight, FolderTree, HardDrive, RefreshCw } from "lucide-react";
+import { useFileViewerStore } from "@/lib/fileViewerStore";
+
+type FileNode = {
+ name: string;
+ path: string;
+ type: "file" | "directory";
+ children?: FileNode[];
+};
+
+function TreeNode({
+ node,
+ expanded,
+ onToggle,
+ onOpen,
+}: {
+ node: FileNode;
+ expanded: Set;
+ onToggle: (path: string) => void;
+ onOpen: (path: string) => void;
+}) {
+ const isDirectory = node.type === "directory";
+ const isExpanded = expanded.has(node.path);
+
+ return (
+
+
(isDirectory ? onToggle(node.path) : onOpen(node.path))}
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm text-surface-300 transition-colors hover:bg-surface-800 hover:text-surface-100"
+ >
+ {isDirectory ? (
+ isExpanded ? :
+ ) : (
+
+ )}
+ {node.name}
+
+
+ {isDirectory && isExpanded && node.children && node.children.length > 0 && (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function FileExplorer() {
+ const loadAndOpen = useFileViewerStore((state) => state.loadAndOpen);
+ const [entries, setEntries] = useState([]);
+ const [absoluteRoot, setAbsoluteRoot] = useState("");
+ const [expanded, setExpanded] = useState>(new Set(["src", "web"]));
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const refresh = async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const response = await fetch("/api/files/list");
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const data = await response.json();
+ setEntries(data.entries ?? []);
+ setAbsoluteRoot(data.absoluteRoot ?? "");
+ } catch (loadError) {
+ setError(loadError instanceof Error ? loadError.message : "Unknown error");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ void refresh();
+ }, []);
+
+ const toggleNode = (path: string) => {
+ setExpanded((current) => {
+ const next = new Set(current);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ };
+
+ const visibleCount = useMemo(() => entries.length, [entries]);
+
+ return (
+
+
+
+
+
+ Workspace
+
+
void refresh()}
+ className="rounded-md p-1 text-surface-500 transition-colors hover:bg-surface-800 hover:text-surface-200"
+ aria-label="Refresh file explorer"
+ >
+
+
+
+
+ Browsing {visibleCount} top-level items from the configured workspace root.
+
+
+
+
+
+
+
+ Workspace root
+
+
{absoluteRoot || "Loading..."}
+
+
+
+ {isLoading ? (
+
Loading files…
+ ) : error ? (
+
{error}
+ ) : (
+ entries.map((entry) => (
+
void loadAndOpen(path)}
+ />
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/web/components/layout/Header.tsx b/web/components/layout/Header.tsx
index f724fe91..b6ce7ec2 100644
--- a/web/components/layout/Header.tsx
+++ b/web/components/layout/Header.tsx
@@ -1,15 +1,26 @@
"use client";
-import { Sun, Moon, Monitor } from "lucide-react";
+import { useMemo, useState } from "react";
+import { PawPrint, Sun, Moon, Monitor } from "lucide-react";
import { useTheme } from "./ThemeProvider";
import { useChatStore } from "@/lib/store";
-import { MODELS } from "@/lib/constants";
+import { getModelOptions } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { NotificationCenter } from "@/components/notifications/NotificationCenter";
+import { ShareDialog } from "@/components/share/ShareDialog";
+import { BuddyWidget } from "@/components/buddy/BuddyWidget";
+import type { BuddyProfile } from "@/lib/buddy";
-export function Header() {
+interface HeaderProps {
+ buddyProfile: BuddyProfile;
+ onOpenBuddy: () => void;
+}
+
+export function Header({ buddyProfile, onOpenBuddy }: HeaderProps) {
const { theme, setTheme } = useTheme();
- const { settings, updateSettings } = useChatStore();
+ const { settings, updateSettings, openSettings, getActiveConversation } = useChatStore();
+ const [shareOpen, setShareOpen] = useState(false);
+ const modelOptions = useMemo(() => getModelOptions(settings.provider), [settings.provider]);
const themeIcons = {
light: Sun,
@@ -21,44 +32,74 @@ export function Header() {
const nextTheme = theme === "dark" ? "light" : theme === "light" ? "system" : "dark";
return (
-
);
}
+
+
+
+
diff --git a/web/components/settings/McpSettings.tsx b/web/components/settings/McpSettings.tsx
index a8fc415a..e3c44f6b 100644
--- a/web/components/settings/McpSettings.tsx
+++ b/web/components/settings/McpSettings.tsx
@@ -1,4 +1,4 @@
-"use client";
+"use client";
import { useState } from "react";
import {
@@ -32,7 +32,7 @@ function ServerRow({
async function testConnection() {
setTestStatus("testing");
- // Simulate connection test — in real impl this would call an API
+ // 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");
}
@@ -61,12 +61,18 @@ function ServerRow({
Test
setExpanded((v) => !v)}
+ type="button"
+ aria-label={expanded ? `Collapse ${server.name} settings` : `Expand ${server.name} settings`}
+ title={expanded ? "Collapse server settings" : "Expand server settings"}
className="text-surface-500 hover:text-surface-300 transition-colors"
>
@@ -91,6 +100,7 @@ function ServerRow({
onUpdate({ ...server, name: e.target.value })}
+ aria-label="Server name"
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"
/>
@@ -100,6 +110,7 @@ function ServerRow({
value={server.command}
onChange={(e) => onUpdate({ ...server, command: e.target.value })}
placeholder="npx, node, python..."
+ aria-label="Server command"
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"
/>
@@ -117,6 +128,7 @@ function ServerRow({
})
}
placeholder="-y @modelcontextprotocol/server-filesystem /path"
+ aria-label="Server arguments"
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"
/>
@@ -163,7 +175,7 @@ export function McpSettings() {
- Model Context Protocol servers extend Claude with external tools and data sources.
+ Model Context Protocol servers extend AG-Claw with external tools and data sources.
@@ -193,6 +205,7 @@ export function McpSettings() {
value={newServer.name}
onChange={(e) => setNewServer((s) => ({ ...s, name: e.target.value }))}
placeholder="filesystem"
+ aria-label="New server name"
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"
/>
@@ -202,6 +215,7 @@ export function McpSettings() {
value={newServer.command}
onChange={(e) => setNewServer((s) => ({ ...s, command: e.target.value }))}
placeholder="npx"
+ aria-label="New server command"
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"
/>
@@ -219,12 +233,14 @@ export function McpSettings() {
}))
}
placeholder="-y @modelcontextprotocol/server-filesystem /path"
+ aria-label="New server arguments"
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"
/>
@@ -19,34 +19,36 @@ export function ModelSettings() {
updateSettings({ model: e.target.value })}
+ onChange={(event) => updateSettings({ model: event.target.value })}
+ aria-label="Default model"
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) => (
-
- {m.label} — {m.description}
+ {modelOptions.map((model) => (
+
+ {model.label} - {model.description}
))}
{selectedModel && (
-
+
{selectedModel.label}
- {" — "}{selectedModel.description}
+ {" - "}
+ {selectedModel.description}
)}
@@ -55,7 +57,7 @@ export function ModelSettings() {
min={1000}
max={200000}
step={1000}
- onChange={(v) => updateSettings({ maxTokens: v })}
+ onChange={(value) => updateSettings({ maxTokens: value })}
showValue={false}
className="flex-1"
/>
@@ -65,9 +67,10 @@ export function ModelSettings() {
min={1000}
max={200000}
step={1000}
- onChange={(e) => updateSettings({ maxTokens: Number(e.target.value) })}
+ onChange={(event) => updateSettings({ maxTokens: Number(event.target.value) })}
+ aria-label="Max tokens"
className={cn(
- "w-24 bg-surface-800 border border-surface-700 rounded-md px-2 py-1 text-sm text-right",
+ "w-24 bg-surface-800 border border-surface-700 rounded-md px-2 py-1 text-right text-sm",
"text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
)}
/>
@@ -81,24 +84,25 @@ export function ModelSettings() {
>