mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-30 20:39:46 +02:00
Add agent selection and artifact management to RowboatX UI
- Implemented agent selection dropdown in the input area. - Enhanced artifact management with loading, saving, and error handling. - Added new API routes for fetching agent summaries and run details. - Updated sidebar to display agents, configurations, and runs dynamically. - Introduced theme selection options in the user navigation menu.
This commit is contained in:
parent
5579798abb
commit
4d80321815
8 changed files with 965 additions and 251 deletions
|
|
@ -427,6 +427,13 @@ export class AgentState {
|
||||||
this.runId = event.runId;
|
this.runId = event.runId;
|
||||||
this.agentName = event.agentName;
|
this.agentName = event.agentName;
|
||||||
break;
|
break;
|
||||||
|
case "spawn-subflow":
|
||||||
|
// Seed the subflow state with its agent so downstream loadAgent works.
|
||||||
|
if (!this.subflowStates[event.toolCallId]) {
|
||||||
|
this.subflowStates[event.toolCallId] = new AgentState();
|
||||||
|
}
|
||||||
|
this.subflowStates[event.toolCallId].agentName = event.agentName;
|
||||||
|
break;
|
||||||
case "message":
|
case "message":
|
||||||
this.messages.push(event.message);
|
this.messages.push(event.message);
|
||||||
if (event.message.content instanceof Array) {
|
if (event.message.content instanceof Array) {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic';
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { message, runId } = body;
|
const { message, runId, agentId } = body;
|
||||||
|
|
||||||
if (!message || typeof message !== 'string') {
|
if (!message || typeof message !== 'string') {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
|
@ -25,7 +25,7 @@ export async function POST(request: NextRequest) {
|
||||||
// Create new run if no runId provided
|
// Create new run if no runId provided
|
||||||
if (!currentRunId) {
|
if (!currentRunId) {
|
||||||
const run = await cliClient.createRun({
|
const run = await cliClient.createRun({
|
||||||
agentId: 'copilot',
|
agentId: agentId || 'copilot',
|
||||||
});
|
});
|
||||||
currentRunId = run.id;
|
currentRunId = run.id;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
apps/rowboatx/app/api/cli/[...path]/route.ts
Normal file
71
apps/rowboatx/app/api/cli/[...path]/route.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const BACKEND = process.env.CLI_BACKEND_URL || "http://localhost:3000";
|
||||||
|
const CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function forward(req: NextRequest, method: string, segments?: string[]) {
|
||||||
|
const search = req.nextUrl.search || "";
|
||||||
|
const targetPath = (segments || []).join("/");
|
||||||
|
const target = `${BACKEND}/${targetPath}${search}`;
|
||||||
|
|
||||||
|
const init: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": req.headers.get("content-type") || "application/json",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (method !== "GET" && method !== "HEAD") {
|
||||||
|
init.body = await req.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(target, init);
|
||||||
|
const body = await res.text();
|
||||||
|
return new Response(body, {
|
||||||
|
status: res.status,
|
||||||
|
headers: {
|
||||||
|
"content-type": res.headers.get("content-type") || "application/json",
|
||||||
|
...CORS_HEADERS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await context.params;
|
||||||
|
return forward(req, "GET", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await context.params;
|
||||||
|
return forward(req, "POST", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await context.params;
|
||||||
|
return forward(req, "PUT", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ path?: string[] }> }
|
||||||
|
) {
|
||||||
|
const { path } = await context.params;
|
||||||
|
return forward(req, "DELETE", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
34
apps/rowboatx/app/api/rowboat/run/route.ts
Normal file
34
apps/rowboatx/app/api/rowboat/run/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
|
||||||
|
const ROWBOAT_ROOT = path.join(os.homedir(), ".rowboat", "runs");
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const fileParam = req.nextUrl.searchParams.get("file");
|
||||||
|
if (!fileParam) {
|
||||||
|
return Response.json({ error: "file param required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent path traversal: only allow basenames.
|
||||||
|
const safeName = path.basename(fileParam);
|
||||||
|
const target = path.join(ROWBOAT_ROOT, safeName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(target, "utf8");
|
||||||
|
let parsed: any = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
return Response.json({ file: safeName, parsed, raw: content });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Failed to read run file", error);
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Failed to read run file" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/rowboatx/app/api/rowboat/summary/route.ts
Normal file
28
apps/rowboatx/app/api/rowboat/summary/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
|
||||||
|
const ROWBOAT_ROOT = path.join(os.homedir(), ".rowboat");
|
||||||
|
|
||||||
|
async function safeList(dir: string): Promise<string[]> {
|
||||||
|
const full = path.join(ROWBOAT_ROOT, dir);
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(full, { withFileTypes: true });
|
||||||
|
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest) {
|
||||||
|
const agents = await safeList("agents");
|
||||||
|
const config = await safeList("config");
|
||||||
|
const runs = await safeList("runs");
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
agents,
|
||||||
|
config,
|
||||||
|
runs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -36,9 +36,27 @@ import { Message, MessageContent, MessageResponse } from "@/components/ai-elemen
|
||||||
import { Conversation, ConversationContent } from "@/components/ai-elements/conversation";
|
import { Conversation, ConversationContent } from "@/components/ai-elements/conversation";
|
||||||
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from "@/components/ai-elements/tool";
|
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from "@/components/ai-elements/tool";
|
||||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning";
|
import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning";
|
||||||
|
import {
|
||||||
|
Artifact,
|
||||||
|
ArtifactAction,
|
||||||
|
ArtifactActions,
|
||||||
|
ArtifactClose,
|
||||||
|
ArtifactContent,
|
||||||
|
ArtifactDescription,
|
||||||
|
ArtifactHeader,
|
||||||
|
ArtifactTitle,
|
||||||
|
} from "@/components/ai-elements/artifact";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { GlobeIcon, MicIcon } from "lucide-react";
|
import { MicIcon, Save, Loader2, Lock } from "lucide-react";
|
||||||
import { RunEvent } from "@/lib/cli-client";
|
import { RunEvent } from "@/lib/cli-client";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -68,13 +86,19 @@ interface ReasoningBlock {
|
||||||
|
|
||||||
type ConversationItem = ChatMessage | ToolCall | ReasoningBlock;
|
type ConversationItem = ChatMessage | ToolCall | ReasoningBlock;
|
||||||
|
|
||||||
export default function HomePage() {
|
type ResourceKind = "agent" | "config" | "run";
|
||||||
|
|
||||||
|
type SelectedResource = {
|
||||||
|
kind: ResourceKind;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PageBody() {
|
||||||
|
// Use local proxy to avoid CORS/port mismatches.
|
||||||
|
const apiBase = "/api/cli";
|
||||||
const [text, setText] = useState<string>("");
|
const [text, setText] = useState<string>("");
|
||||||
const [useWebSearch, setUseWebSearch] = useState<boolean>(false);
|
|
||||||
const [useMicrophone, setUseMicrophone] = useState<boolean>(false);
|
const [useMicrophone, setUseMicrophone] = useState<boolean>(false);
|
||||||
const [status, setStatus] = useState<
|
const [status, setStatus] = useState<"submitted" | "streaming" | "ready" | "error">("ready");
|
||||||
"submitted" | "streaming" | "ready" | "error"
|
|
||||||
>("ready");
|
|
||||||
|
|
||||||
// Chat state
|
// Chat state
|
||||||
const [runId, setRunId] = useState<string | null>(null);
|
const [runId, setRunId] = useState<string | null>(null);
|
||||||
|
|
@ -85,6 +109,72 @@ export default function HomePage() {
|
||||||
const committedMessageIds = useRef<Set<string>>(new Set());
|
const committedMessageIds = useRef<Set<string>>(new Set());
|
||||||
const isEmptyConversation =
|
const isEmptyConversation =
|
||||||
conversation.length === 0 && !currentAssistantMessage && !currentReasoning;
|
conversation.length === 0 && !currentAssistantMessage && !currentReasoning;
|
||||||
|
const [selectedResource, setSelectedResource] = useState<SelectedResource | null>(null);
|
||||||
|
const [artifactTitle, setArtifactTitle] = useState("");
|
||||||
|
const [artifactSubtitle, setArtifactSubtitle] = useState("");
|
||||||
|
const [artifactText, setArtifactText] = useState("");
|
||||||
|
const [artifactOriginal, setArtifactOriginal] = useState("");
|
||||||
|
const [artifactLoading, setArtifactLoading] = useState(false);
|
||||||
|
const [artifactError, setArtifactError] = useState<string | null>(null);
|
||||||
|
const [artifactReadOnly, setArtifactReadOnly] = useState(false);
|
||||||
|
const [agentOptions, setAgentOptions] = useState<string[]>(["copilot"]);
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<string>("copilot");
|
||||||
|
|
||||||
|
const artifactDirty = !artifactReadOnly && artifactText !== artifactOriginal;
|
||||||
|
const stripExtension = (name: string) => name.replace(/\.[^/.]+$/, "");
|
||||||
|
|
||||||
|
const requestJson = async (
|
||||||
|
url: string,
|
||||||
|
options?: (RequestInit & { allow404?: boolean }) | undefined
|
||||||
|
) => {
|
||||||
|
const isLocalApi = url.startsWith("/api/rowboat");
|
||||||
|
const fullUrl =
|
||||||
|
url.startsWith("http://") || url.startsWith("https://") || isLocalApi
|
||||||
|
? url
|
||||||
|
: apiBase
|
||||||
|
? `${apiBase}${url}`
|
||||||
|
: url;
|
||||||
|
const { allow404, ...rest } = options || {};
|
||||||
|
const res = await fetch(fullUrl, {
|
||||||
|
...rest,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(rest.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = res.headers.get("content-type")?.toLowerCase() ?? "";
|
||||||
|
const isJson = contentType.includes("application/json");
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404 && allow404) return null;
|
||||||
|
if (isJson) {
|
||||||
|
try {
|
||||||
|
const errObj = JSON.parse(text);
|
||||||
|
const errMsg =
|
||||||
|
typeof errObj === "string"
|
||||||
|
? errObj
|
||||||
|
: errObj?.message || errObj?.error || JSON.stringify(errObj);
|
||||||
|
throw new Error(errMsg || `Request failed: ${res.status} ${res.statusText}`);
|
||||||
|
} catch {
|
||||||
|
/* fall through to generic error */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (res.status === 404) {
|
||||||
|
throw new Error("Resource not found on the CLI backend (404)");
|
||||||
|
}
|
||||||
|
throw new Error(`Request failed: ${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text) return null;
|
||||||
|
if (!isJson) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderPromptInput = () => (
|
const renderPromptInput = () => (
|
||||||
<PromptInput globalDrop multiple onSubmit={handleSubmit}>
|
<PromptInput globalDrop multiple onSubmit={handleSubmit}>
|
||||||
|
|
@ -116,13 +206,23 @@ export default function HomePage() {
|
||||||
<MicIcon size={16} />
|
<MicIcon size={16} />
|
||||||
<span className="sr-only">Microphone</span>
|
<span className="sr-only">Microphone</span>
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
<PromptInputButton
|
<Select
|
||||||
onClick={() => setUseWebSearch(!useWebSearch)}
|
value={selectedAgent}
|
||||||
variant={useWebSearch ? "default" : "ghost"}
|
onValueChange={(value) => setSelectedAgent(value)}
|
||||||
>
|
>
|
||||||
<GlobeIcon size={16} />
|
<SelectTrigger className="w-32">
|
||||||
<span>Search</span>
|
<SelectValue placeholder="Agent" />
|
||||||
</PromptInputButton>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{agentOptions.map((agent) => (
|
||||||
|
<SelectItem key={agent} value={agent}>
|
||||||
|
{agent}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</PromptInputTools>
|
</PromptInputTools>
|
||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
disabled={!(text.trim() || status) || status === "streaming"}
|
disabled={!(text.trim() || status) || status === "streaming"}
|
||||||
|
|
@ -238,27 +338,37 @@ export default function HomePage() {
|
||||||
);
|
);
|
||||||
if (toolCalls.length) {
|
if (toolCalls.length) {
|
||||||
setConversation((prev) => {
|
setConversation((prev) => {
|
||||||
const updated = [...prev];
|
let updated: ConversationItem[] = prev.map((item) => {
|
||||||
|
if (item.type !== 'tool') return item;
|
||||||
|
const match = toolCalls.find(
|
||||||
|
(part: any) => part.toolCallId === item.id
|
||||||
|
);
|
||||||
|
return match
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
name: match.toolName,
|
||||||
|
input: match.arguments,
|
||||||
|
status: 'pending',
|
||||||
|
}
|
||||||
|
: item;
|
||||||
|
});
|
||||||
|
|
||||||
for (const part of toolCalls) {
|
for (const part of toolCalls) {
|
||||||
const idx = updated.findIndex(
|
const exists = updated.some(
|
||||||
(item) => item.type === 'tool' && item.id === part.toolCallId
|
(item) => item.type === 'tool' && item.id === part.toolCallId
|
||||||
);
|
);
|
||||||
if (idx >= 0) {
|
if (!exists) {
|
||||||
updated[idx] = {
|
updated = [
|
||||||
...updated[idx],
|
...updated,
|
||||||
name: part.toolName,
|
{
|
||||||
input: part.arguments,
|
id: part.toolCallId,
|
||||||
status: 'pending',
|
type: 'tool',
|
||||||
};
|
name: part.toolName,
|
||||||
} else {
|
input: part.arguments,
|
||||||
updated.push({
|
status: 'pending',
|
||||||
id: part.toolCallId,
|
timestamp: Date.now(),
|
||||||
type: 'tool',
|
},
|
||||||
name: part.toolName,
|
];
|
||||||
input: part.arguments,
|
|
||||||
status: 'pending',
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
|
|
@ -362,6 +472,7 @@ export default function HomePage() {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: userMessage,
|
message: userMessage,
|
||||||
|
agentId: selectedAgent,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
@ -385,9 +496,178 @@ export default function HomePage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedResource) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
setArtifactLoading(true);
|
||||||
|
setArtifactError(null);
|
||||||
|
try {
|
||||||
|
let title = selectedResource.name;
|
||||||
|
let subtitle = "";
|
||||||
|
let text = "";
|
||||||
|
let readOnly = false;
|
||||||
|
|
||||||
|
if (selectedResource.kind === "agent") {
|
||||||
|
const raw = selectedResource.name;
|
||||||
|
const id = stripExtension(raw) || raw;
|
||||||
|
const data = await requestJson(`/agents/${encodeURIComponent(id)}`);
|
||||||
|
|
||||||
|
subtitle = "Agent";
|
||||||
|
text = JSON.stringify(data ?? {}, null, 2);
|
||||||
|
} else if (selectedResource.kind === "config") {
|
||||||
|
const lower = selectedResource.name.toLowerCase();
|
||||||
|
if (lower.includes("mcp")) {
|
||||||
|
const data = await requestJson("/mcp");
|
||||||
|
subtitle = "MCP config";
|
||||||
|
text = JSON.stringify(data ?? {}, null, 2);
|
||||||
|
} else if (lower.includes("model")) {
|
||||||
|
const data = await requestJson("/models");
|
||||||
|
subtitle = "Models config";
|
||||||
|
text = JSON.stringify(data ?? {}, null, 2);
|
||||||
|
} else {
|
||||||
|
throw new Error("Unsupported config file");
|
||||||
|
}
|
||||||
|
} else if (selectedResource.kind === "run") {
|
||||||
|
subtitle = "Run (read-only)";
|
||||||
|
readOnly = true;
|
||||||
|
|
||||||
|
const local = await requestJson(
|
||||||
|
`/api/rowboat/run?file=${encodeURIComponent(selectedResource.name)}`
|
||||||
|
);
|
||||||
|
if (local?.parsed) {
|
||||||
|
text = JSON.stringify(local.parsed, null, 2);
|
||||||
|
} else if (local?.raw) {
|
||||||
|
text = local.raw;
|
||||||
|
} else {
|
||||||
|
text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
setArtifactTitle(title);
|
||||||
|
setArtifactSubtitle(subtitle);
|
||||||
|
setArtifactText(text);
|
||||||
|
setArtifactOriginal(text);
|
||||||
|
setArtifactReadOnly(readOnly);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setArtifactError(error?.message || "Failed to load resource");
|
||||||
|
setArtifactText("");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setArtifactLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedResource]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAgents = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/rowboat/summary");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
const agents = Array.isArray(data.agents)
|
||||||
|
? data.agents.map((a: string) => stripExtension(a))
|
||||||
|
: [];
|
||||||
|
const merged = Array.from(new Set(["copilot", ...agents]));
|
||||||
|
setAgentOptions(merged);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load agent list", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAgents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Changing agent starts a fresh conversation context
|
||||||
|
setRunId(null);
|
||||||
|
setConversation([]);
|
||||||
|
setCurrentAssistantMessage("");
|
||||||
|
setCurrentReasoning("");
|
||||||
|
}, [selectedAgent]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedResource || artifactReadOnly || !artifactDirty) return;
|
||||||
|
setArtifactLoading(true);
|
||||||
|
setArtifactError(null);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(artifactText);
|
||||||
|
if (selectedResource.kind === "agent") {
|
||||||
|
const raw = selectedResource.name;
|
||||||
|
const targetId = stripExtension(raw) || raw;
|
||||||
|
|
||||||
|
await requestJson(`/agents/${encodeURIComponent(targetId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(parsed),
|
||||||
|
});
|
||||||
|
} else if (selectedResource.kind === "config") {
|
||||||
|
const lower = selectedResource.name.toLowerCase();
|
||||||
|
const previous = artifactOriginal ? JSON.parse(artifactOriginal) : {};
|
||||||
|
|
||||||
|
if (lower.includes("model")) {
|
||||||
|
const newProviders = parsed.providers || {};
|
||||||
|
const oldProviders = previous.providers || {};
|
||||||
|
const toDelete = Object.keys(oldProviders).filter(
|
||||||
|
(name) => !Object.prototype.hasOwnProperty.call(newProviders, name)
|
||||||
|
);
|
||||||
|
for (const name of toDelete) {
|
||||||
|
await requestJson(`/models/providers/${encodeURIComponent(name)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const name of Object.keys(newProviders)) {
|
||||||
|
await requestJson(`/models/providers/${encodeURIComponent(name)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(newProviders[name]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (parsed.defaults) {
|
||||||
|
await requestJson("/models/default", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(parsed.defaults),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (lower.includes("mcp")) {
|
||||||
|
const newServers = parsed.mcpServers || parsed || {};
|
||||||
|
const oldServers = previous.mcpServers || {};
|
||||||
|
const toDelete = Object.keys(oldServers).filter(
|
||||||
|
(name) => !Object.prototype.hasOwnProperty.call(newServers, name)
|
||||||
|
);
|
||||||
|
for (const name of toDelete) {
|
||||||
|
await requestJson(`/mcp/${encodeURIComponent(name)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const name of Object.keys(newServers)) {
|
||||||
|
await requestJson(`/mcp/${encodeURIComponent(name)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(newServers[name]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("Unsupported config file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setArtifactOriginal(JSON.stringify(JSON.parse(artifactText), null, 2));
|
||||||
|
} catch (error: any) {
|
||||||
|
setArtifactError(error?.message || "Failed to save changes");
|
||||||
|
} finally {
|
||||||
|
setArtifactLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<>
|
||||||
<AppSidebar />
|
<AppSidebar onSelectResource={setSelectedResource} />
|
||||||
<SidebarInset className="h-svh">
|
<SidebarInset className="h-svh">
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||||
<div className="flex items-center gap-2 px-4">
|
<div className="flex items-center gap-2 px-4">
|
||||||
|
|
@ -410,113 +690,191 @@ export default function HomePage() {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="relative flex w-full flex-1 min-h-0 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col gap-4 overflow-hidden px-4 pb-0 md:flex-row">
|
||||||
{/* Messages area */}
|
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden">
|
||||||
<Conversation className="flex-1 min-h-0 pb-48">
|
{/* Messages area */}
|
||||||
<ConversationContent className="!flex !flex-col !items-center !gap-8 !p-4">
|
<Conversation className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<div className="w-full max-w-3xl mx-auto space-y-4">
|
<div className="pointer-events-none sticky bottom-0 z-10 h-16 bg-gradient-to-t from-background via-background/80 to-transparent" />
|
||||||
|
<ConversationContent className="!flex !flex-col !items-center !gap-8 !p-4 pt-4 pb-32">
|
||||||
|
<div className="w-full max-w-3xl mx-auto space-y-4">
|
||||||
|
|
||||||
{/* Render conversation items in order */}
|
{/* Render conversation items in order */}
|
||||||
{conversation.map((item) => {
|
{conversation.map((item) => {
|
||||||
if (item.type === 'message') {
|
if (item.type === 'message') {
|
||||||
return (
|
return (
|
||||||
<Message
|
<Message
|
||||||
key={item.id}
|
key={item.id}
|
||||||
from={item.role}
|
from={item.role}
|
||||||
>
|
>
|
||||||
<MessageContent>
|
<MessageContent>
|
||||||
<MessageResponse>
|
<MessageResponse>
|
||||||
{item.content}
|
{item.content}
|
||||||
</MessageResponse>
|
</MessageResponse>
|
||||||
</MessageContent>
|
</MessageContent>
|
||||||
</Message>
|
</Message>
|
||||||
);
|
);
|
||||||
} else if (item.type === 'tool') {
|
} else if (item.type === 'tool') {
|
||||||
const stateMap: Record<string, any> = {
|
const stateMap: Record<string, any> = {
|
||||||
'pending': 'input-streaming',
|
'pending': 'input-streaming',
|
||||||
'running': 'input-available',
|
'running': 'input-available',
|
||||||
'completed': 'output-available',
|
'completed': 'output-available',
|
||||||
'error': 'output-error',
|
'error': 'output-error',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="mb-2">
|
<div key={item.id} className="mb-2">
|
||||||
<Tool>
|
<Tool>
|
||||||
<ToolHeader
|
<ToolHeader
|
||||||
title={item.name}
|
title={item.name}
|
||||||
type="tool-call"
|
type="tool-call"
|
||||||
state={stateMap[item.status] || 'input-streaming'}
|
state={stateMap[item.status] || 'input-streaming'}
|
||||||
/>
|
/>
|
||||||
<ToolContent>
|
<ToolContent>
|
||||||
<ToolInput input={item.input} />
|
<ToolInput input={item.input} />
|
||||||
{item.result && (
|
{item.result && (
|
||||||
<ToolOutput output={item.result} errorText={undefined} />
|
<ToolOutput output={item.result} errorText={undefined} />
|
||||||
)}
|
)}
|
||||||
</ToolContent>
|
</ToolContent>
|
||||||
</Tool>
|
</Tool>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (item.type === 'reasoning') {
|
} else if (item.type === 'reasoning') {
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="mb-2">
|
<div key={item.id} className="mb-2">
|
||||||
<Reasoning isStreaming={item.isStreaming}>
|
<Reasoning isStreaming={item.isStreaming}>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
{item.content}
|
{item.content}
|
||||||
</ReasoningContent>
|
</ReasoningContent>
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Streaming reasoning */}
|
{/* Streaming reasoning */}
|
||||||
{currentReasoning && (
|
{currentReasoning && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<Reasoning isStreaming={true}>
|
<Reasoning isStreaming={true}>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>
|
<ReasoningContent>
|
||||||
{currentReasoning}
|
{currentReasoning}
|
||||||
</ReasoningContent>
|
</ReasoningContent>
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Streaming message */}
|
||||||
|
{currentAssistantMessage && (
|
||||||
|
<Message from="assistant">
|
||||||
|
<MessageContent>
|
||||||
|
<MessageResponse>
|
||||||
|
{currentAssistantMessage}
|
||||||
|
</MessageResponse>
|
||||||
|
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse" />
|
||||||
|
</MessageContent>
|
||||||
|
</Message>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</ConversationContent>
|
||||||
|
</Conversation>
|
||||||
|
|
||||||
{/* Streaming message */}
|
{/* Input area */}
|
||||||
{currentAssistantMessage && (
|
{isEmptyConversation ? (
|
||||||
<Message from="assistant">
|
<div className="absolute inset-0 flex items-center justify-center px-4 pb-16">
|
||||||
<MessageContent>
|
<div className="w-full max-w-3xl space-y-3 text-center">
|
||||||
<MessageResponse>
|
<h2 className="text-4xl font-semibold text-foreground/80">
|
||||||
{currentAssistantMessage}
|
RowboatX
|
||||||
</MessageResponse>
|
</h2>
|
||||||
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse" />
|
{renderPromptInput()}
|
||||||
</MessageContent>
|
</div>
|
||||||
</Message>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ConversationContent>
|
) : (
|
||||||
</Conversation>
|
<div className="w-full px-4 pb-5 pt-2">
|
||||||
|
<div className="w-full max-w-3xl mx-auto">
|
||||||
|
{renderPromptInput()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Input area */}
|
{selectedResource && (
|
||||||
{isEmptyConversation ? (
|
<div className="flex w-full flex-col md:w-[70%] md:max-w-4xl md:shrink-0 min-h-[260px] md:min-h-0 py-5">
|
||||||
<div className="absolute inset-0 flex items-center justify-center px-4 pb-16">
|
<Artifact className="flex-1 min-h-0 h-full">
|
||||||
<div className="w-full max-w-3xl space-y-3 text-center">
|
<ArtifactHeader>
|
||||||
<h2 className="text-4xl font-semibold text-foreground/80">
|
<div className="flex flex-col">
|
||||||
RowboatX
|
<ArtifactTitle className="truncate">{artifactTitle}</ArtifactTitle>
|
||||||
</h2>
|
<ArtifactDescription className="text-xs">
|
||||||
{renderPromptInput()}
|
{artifactSubtitle || selectedResource.kind}
|
||||||
</div>
|
{artifactReadOnly && (
|
||||||
</div>
|
<span className="ml-2 inline-flex items-center gap-1 text-muted-foreground">
|
||||||
) : (
|
<Lock className="h-3 w-3" /> Read-only
|
||||||
<div className="absolute bottom-2 left-0 right-0 flex justify-center w-full px-4 pb-5 pt-1 bg-background/95 backdrop-blur-sm">
|
</span>
|
||||||
<div className="w-full max-w-3xl">
|
)}
|
||||||
{renderPromptInput()}
|
</ArtifactDescription>
|
||||||
</div>
|
</div>
|
||||||
|
<ArtifactActions>
|
||||||
|
{!artifactReadOnly && (
|
||||||
|
<ArtifactAction
|
||||||
|
tooltip={artifactDirty ? "Save changes" : "Saved"}
|
||||||
|
disabled={!artifactDirty || artifactLoading}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{artifactLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</ArtifactAction>
|
||||||
|
)}
|
||||||
|
<ArtifactClose onClick={() => setSelectedResource(null)} />
|
||||||
|
</ArtifactActions>
|
||||||
|
</ArtifactHeader>
|
||||||
|
<ArtifactContent className="bg-muted/30">
|
||||||
|
{artifactLoading ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
|
||||||
|
</div>
|
||||||
|
) : artifactError ? (
|
||||||
|
<div className="text-sm text-red-500 whitespace-pre-wrap break-words">
|
||||||
|
{artifactError}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col gap-2">
|
||||||
|
{artifactReadOnly ? (
|
||||||
|
<pre className="h-full min-h-[240px] max-h-[70vh] w-full overflow-auto whitespace-pre-wrap rounded-md border bg-background p-4 font-mono text-sm leading-relaxed text-foreground">
|
||||||
|
{artifactText}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
value={artifactText}
|
||||||
|
onChange={(e) => setArtifactText(e.target.value)}
|
||||||
|
readOnly={artifactReadOnly}
|
||||||
|
className="h-full min-h-[240px] max-h-[70vh] w-full resize-none rounded-md border bg-background p-4 font-mono text-sm leading-relaxed text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{artifactReadOnly && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Runs are read-only; use the API to replay or inspect in detail.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ArtifactContent>
|
||||||
|
</Artifact>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
<PageBody />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,117 +1,55 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {
|
import { ChevronRight, Clock3, FileText, Folder, Play, Plug, Rocket, Users } from "lucide-react"
|
||||||
AudioWaveform,
|
|
||||||
Bot,
|
|
||||||
Calendar,
|
|
||||||
Command,
|
|
||||||
GalleryVerticalEnd,
|
|
||||||
Play,
|
|
||||||
Plug,
|
|
||||||
Users,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { NavMain } from "@/components/nav-main"
|
|
||||||
import { NavProjects } from "@/components/nav-projects"
|
|
||||||
import { NavUser } from "@/components/nav-user"
|
import { NavUser } from "@/components/nav-user"
|
||||||
import { TeamSwitcher } from "@/components/team-switcher"
|
import { TeamSwitcher } from "@/components/team-switcher"
|
||||||
|
import { NavMain } from "@/components/nav-main"
|
||||||
|
import { NavProjects } from "@/components/nav-projects"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||||
|
|
||||||
// This is sample data.
|
// This is sample data.
|
||||||
const data = {
|
const data = {
|
||||||
user: {
|
user: {
|
||||||
name: "shadcn",
|
name: "user",
|
||||||
email: "m@example.com",
|
email: "user@example.com",
|
||||||
avatar: "/avatars/shadcn.jpg",
|
avatar: "/avatars/user.jpg",
|
||||||
},
|
},
|
||||||
teams: [
|
teams: [
|
||||||
{
|
{
|
||||||
name: "Acme Inc",
|
name: "RowboatX",
|
||||||
logo: GalleryVerticalEnd,
|
logo: Users,
|
||||||
plan: "Enterprise",
|
plan: "Workspace",
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Acme Corp.",
|
|
||||||
logo: AudioWaveform,
|
|
||||||
plan: "Startup",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Evil Corp.",
|
|
||||||
logo: Command,
|
|
||||||
plan: "Free",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
chatHistory: [
|
||||||
|
{ name: "Building a React Dashboard", url: "#" },
|
||||||
|
{ name: "API Integration Best Practices", url: "#" },
|
||||||
|
{ name: "TypeScript Migration Guide", url: "#" },
|
||||||
|
{ name: "Database Optimization Tips", url: "#" },
|
||||||
|
{ name: "Docker Container Setup", url: "#" },
|
||||||
|
{ name: "GraphQL vs REST API", url: "#" },
|
||||||
|
],
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
|
||||||
title: "Agents",
|
|
||||||
url: "#",
|
|
||||||
icon: Users,
|
|
||||||
isActive: true,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "View All Agents",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Create Agent",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Agent Templates",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "MCP",
|
|
||||||
url: "#",
|
|
||||||
icon: Plug,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Servers",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Tools",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Configuration",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Runs",
|
|
||||||
url: "#",
|
|
||||||
icon: Play,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Active Runs",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "History",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Failed Runs",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Scheduled",
|
title: "Scheduled",
|
||||||
url: "#",
|
url: "#",
|
||||||
icon: Calendar,
|
icon: Clock3,
|
||||||
|
isActive: false,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "View Schedule",
|
title: "View Schedule",
|
||||||
|
|
@ -130,7 +68,7 @@ const data = {
|
||||||
{
|
{
|
||||||
title: "Applets",
|
title: "Applets",
|
||||||
url: "#",
|
url: "#",
|
||||||
icon: Zap,
|
icon: Rocket,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Browse Applets",
|
title: "Browse Applets",
|
||||||
|
|
@ -147,42 +85,259 @@ const data = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
chatHistory: [
|
|
||||||
{
|
|
||||||
name: "Building a React Dashboard",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "API Integration Best Practices",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "TypeScript Migration Guide",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Database Optimization Tips",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Docker Container Setup",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "GraphQL vs REST API",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
type RowboatSummary = {
|
||||||
|
agents: string[]
|
||||||
|
config: string[]
|
||||||
|
runs: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceKind = "agent" | "config" | "run"
|
||||||
|
|
||||||
|
type SidebarSelect = (item: { kind: ResourceKind; name: string }) => void
|
||||||
|
|
||||||
|
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
||||||
|
onSelectResource?: SidebarSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSidebar({ onSelectResource, ...props }: AppSidebarProps) {
|
||||||
|
const { state: sidebarState } = useSidebar()
|
||||||
|
const [summary, setSummary] = React.useState<RowboatSummary>({
|
||||||
|
agents: [],
|
||||||
|
config: [],
|
||||||
|
runs: [],
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/rowboat/summary")
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
setSummary({
|
||||||
|
agents: data.agents || [],
|
||||||
|
config: data.config || [],
|
||||||
|
runs: data.runs || [],
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load rowboat summary", error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Limit runs shown and provide "View more" affordance similar to chat history.
|
||||||
|
const runsLimit = 8
|
||||||
|
const visibleRuns = summary.runs.slice(0, runsLimit)
|
||||||
|
const hasMoreRuns = summary.runs.length > runsLimit
|
||||||
|
|
||||||
|
const handleSelect = (kind: ResourceKind, name: string) => {
|
||||||
|
onSelectResource?.({ kind, name })
|
||||||
|
}
|
||||||
|
|
||||||
|
const navInitial = React.useMemo(
|
||||||
|
() =>
|
||||||
|
data.navMain.reduce<Record<string, boolean>>((acc, item) => {
|
||||||
|
acc[item.title] = false
|
||||||
|
return acc
|
||||||
|
}, {}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [openGroups, setOpenGroups] = React.useState<Record<string, boolean>>({
|
||||||
|
agents: false,
|
||||||
|
config: false,
|
||||||
|
runs: false,
|
||||||
|
...navInitial,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isCollapsed = sidebarState === "collapsed"
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isCollapsed) {
|
||||||
|
setOpenGroups((prev) => {
|
||||||
|
const closed: Record<string, boolean> = {}
|
||||||
|
for (const key of Object.keys(prev)) closed[key] = false
|
||||||
|
return closed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [isCollapsed])
|
||||||
|
|
||||||
|
const handleOpenChange = (key: string, next: boolean) => {
|
||||||
|
if (isCollapsed) return
|
||||||
|
setOpenGroups((prev) => ({ ...prev, [key]: next }))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" {...props}>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<TeamSwitcher teams={data.teams} />
|
<TeamSwitcher teams={data.teams} />
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={data.navMain} />
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
<Collapsible
|
||||||
|
className="group/collapsible"
|
||||||
|
open={openGroups.agents}
|
||||||
|
onOpenChange={(open) => handleOpenChange("agents", open)}
|
||||||
|
>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton className="h-9">
|
||||||
|
<Folder className="mr-2 h-4 w-4" />
|
||||||
|
<span className="truncate">Agents</span>
|
||||||
|
<ChevronRight className="ml-auto h-3.5 w-3.5 transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<CollapsibleContent asChild>
|
||||||
|
<SidebarMenu className="pl-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground">Loading…</div>
|
||||||
|
) : summary.agents.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground">No agents found</div>
|
||||||
|
) : (
|
||||||
|
summary.agents.map((name) => (
|
||||||
|
<SidebarMenuItem key={name}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
className="pl-8 h-8"
|
||||||
|
onClick={() => handleSelect("agent", name)}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-3.5 w-3.5" />
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SidebarMenu>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<Collapsible
|
||||||
|
className="group/collapsible"
|
||||||
|
open={openGroups.config}
|
||||||
|
onOpenChange={(open) => handleOpenChange("config", open)}
|
||||||
|
>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton className="h-9">
|
||||||
|
<Plug className="mr-2 h-4 w-4" />
|
||||||
|
<span className="truncate">Config</span>
|
||||||
|
<ChevronRight className="ml-auto h-3.5 w-3.5 transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<CollapsibleContent asChild>
|
||||||
|
<SidebarMenu className="pl-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground">Loading…</div>
|
||||||
|
) : summary.config.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground">No config files</div>
|
||||||
|
) : (
|
||||||
|
summary.config.map((name) => (
|
||||||
|
<SidebarMenuItem key={name}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
className="pl-8 h-8"
|
||||||
|
onClick={() => handleSelect("config", name)}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-3.5 w-3.5" />
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SidebarMenu>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<Collapsible
|
||||||
|
className="group/collapsible"
|
||||||
|
open={openGroups.runs}
|
||||||
|
onOpenChange={(open) => handleOpenChange("runs", open)}
|
||||||
|
>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton className="h-9">
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
<span className="truncate">Runs</span>
|
||||||
|
<ChevronRight className="ml-auto h-3.5 w-3.5 transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
<CollapsibleContent asChild>
|
||||||
|
<SidebarMenu className="pl-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground">Loading…</div>
|
||||||
|
) : summary.runs.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-xs text-muted-foreground">No runs found</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{visibleRuns.map((name) => (
|
||||||
|
<SidebarMenuItem key={name}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
className="pl-8 h-8"
|
||||||
|
onClick={() => handleSelect("run", name)}
|
||||||
|
>
|
||||||
|
<FileText className="mr-2 h-3.5 w-3.5" />
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
{hasMoreRuns && (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton className="pl-8 h-8 text-muted-foreground">
|
||||||
|
<span className="truncate">View more…</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SidebarMenu>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{data.navMain.map((item) => (
|
||||||
|
<Collapsible
|
||||||
|
key={item.title}
|
||||||
|
className="group/collapsible"
|
||||||
|
open={openGroups[item.title]}
|
||||||
|
onOpenChange={(open) => handleOpenChange(item.title, open)}
|
||||||
|
>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton className="h-9">
|
||||||
|
{item.title === "Scheduled" ? (
|
||||||
|
<Clock3 className="mr-2 h-4 w-4" />
|
||||||
|
) : item.title === "Applets" ? (
|
||||||
|
<Rocket className="mr-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Folder className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{item.title}</span>
|
||||||
|
<ChevronRight className="ml-auto h-3.5 w-3.5 transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent asChild>
|
||||||
|
<SidebarMenu className="pl-2">
|
||||||
|
{item.items?.map((sub) => (
|
||||||
|
<SidebarMenuItem key={sub.title}>
|
||||||
|
<SidebarMenuButton className="pl-8 h-8">
|
||||||
|
<span className="truncate">{sub.title}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
<NavProjects projects={data.chatHistory} />
|
<NavProjects projects={data.chatHistory} />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
|
@ -192,5 +347,3 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
MonitorCog,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
|
@ -40,6 +44,40 @@ export function NavUser({
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark" | "system">("system")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const saved = (localStorage.getItem("theme") as "light" | "dark" | "system") || "system"
|
||||||
|
setTheme(saved)
|
||||||
|
applyTheme(saved)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
if (theme !== "system") return
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
const listener = () => applyTheme("system")
|
||||||
|
media.addEventListener("change", listener)
|
||||||
|
return () => media.removeEventListener("change", listener)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const applyTheme = (value: "light" | "dark" | "system") => {
|
||||||
|
const resolved =
|
||||||
|
value === "system"
|
||||||
|
? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
||||||
|
: value
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.toggle("dark", resolved === "dark")
|
||||||
|
localStorage.setItem("theme", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTheme = (value: "light" | "dark" | "system") => {
|
||||||
|
setTheme(value)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
applyTheme(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
|
|
@ -87,6 +125,31 @@ export function NavUser({
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={theme === "light" ? "bg-muted" : ""}
|
||||||
|
onClick={() => handleTheme("light")}
|
||||||
|
>
|
||||||
|
<Sun className="mr-2" />
|
||||||
|
Light
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={theme === "dark" ? "bg-muted" : ""}
|
||||||
|
onClick={() => handleTheme("dark")}
|
||||||
|
>
|
||||||
|
<Moon className="mr-2" />
|
||||||
|
Dark
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={theme === "system" ? "bg-muted" : ""}
|
||||||
|
onClick={() => handleTheme("system")}
|
||||||
|
>
|
||||||
|
<MonitorCog className="mr-2" />
|
||||||
|
System
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<BadgeCheck />
|
<BadgeCheck />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue