diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 342ef58..56e8d6f 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -13,7 +13,7 @@ import { buildAgentContext } from '@/lib/settings/agent-context' import { agentPrompt } from '@/lib/sin/agents' import { getSinTools } from '@/lib/sin/mcp' import { resolveModel } from '@/lib/sin/models' -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' import { SIN_CODE_INSTALL_CMD, SIN_MCP_TOOLS } from '@/lib/sin/tools' import { getSession } from '@/lib/session' import { getWorkspace, BUILT_IN_WORKSPACES } from '@/lib/workspaces' diff --git a/app/api/chats/[id]/export/route.ts b/app/api/chats/[id]/export/route.ts index b46116c..816a6c2 100644 --- a/app/api/chats/[id]/export/route.ts +++ b/app/api/chats/[id]/export/route.ts @@ -5,7 +5,7 @@ */ import { chatToMarkdown } from '@/lib/chat-export' import { isValidChatId, listChats, loadMessages } from '@/lib/storage' -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' export async function GET( req: Request, diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts index 69d3d89..60bfa6d 100644 --- a/app/api/chats/[id]/route.ts +++ b/app/api/chats/[id]/route.ts @@ -11,7 +11,7 @@ import { loadMessages, saveMessages, } from '@/lib/storage' -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' type Params = { params: Promise<{ id: string }> } diff --git a/app/api/chats/[id]/share/route.ts b/app/api/chats/[id]/share/route.ts index 98203af..ed8723f 100644 --- a/app/api/chats/[id]/share/route.ts +++ b/app/api/chats/[id]/share/route.ts @@ -4,7 +4,7 @@ * POST /api/chats/[id]/share — create share, returns public URL slug * DELETE /api/chats/[id]/share — revoke share */ -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' import { getSession } from '@/lib/session' import { getShareByChatId, shareChat, unshareChat } from '@/lib/shares' import { isValidChatId, ownsChat } from '@/lib/storage' diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index 9cce538..0b0504b 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -1,5 +1,5 @@ import { isValidChatId, listChats, upsertChatMeta } from '@/lib/storage' -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' import { getSession } from '@/lib/session' export async function GET(req: Request) { diff --git a/app/api/health/route.ts b/app/api/health/route.ts index b295c40..68212d4 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -4,6 +4,9 @@ */ import { isDbConfigured, getPool } from '@/lib/db' +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + export async function GET() { let db: 'ok' | 'error' | 'file' = 'file' if (isDbConfigured()) { diff --git a/app/api/settings/activity/route.ts b/app/api/settings/activity/route.ts index d9e504a..1bbd647 100644 --- a/app/api/settings/activity/route.ts +++ b/app/api/settings/activity/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" import { readActivity, summarize } from "@/lib/settings/activity" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" export async function GET(req: Request) { diff --git a/app/api/settings/api-keys/route.ts b/app/api/settings/api-keys/route.ts index 193d761..07cdcc4 100644 --- a/app/api/settings/api-keys/route.ts +++ b/app/api/settings/api-keys/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" import { listApiKeys, createApiKey, revokeApiKey } from "@/lib/settings/api-keys" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" export async function GET(req: Request) { diff --git a/app/api/settings/files/route.ts b/app/api/settings/files/route.ts index 928df8c..d3819b4 100644 --- a/app/api/settings/files/route.ts +++ b/app/api/settings/files/route.ts @@ -6,7 +6,7 @@ import { deleteFile, type Scope, } from "@/lib/settings/store" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" type Kind = "memories" | "skills" diff --git a/app/api/settings/mcp/route.ts b/app/api/settings/mcp/route.ts index 5dc319c..9be4f1b 100644 --- a/app/api/settings/mcp/route.ts +++ b/app/api/settings/mcp/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server" import { promises as fs } from "fs" import path from "path" import crypto from "crypto" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" let _base: string | null = null diff --git a/app/api/settings/members/route.ts b/app/api/settings/members/route.ts index 392fb95..8d4fc06 100644 --- a/app/api/settings/members/route.ts +++ b/app/api/settings/members/route.ts @@ -6,7 +6,7 @@ import { NextResponse } from "next/server" import { getPool } from "@/lib/db" import { isBetterAuthEnabled } from "@/lib/auth/better-auth" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" async function requireAdmin(req: Request): Promise { diff --git a/app/api/settings/preferences/route.ts b/app/api/settings/preferences/route.ts index 03a2aeb..955b0ea 100644 --- a/app/api/settings/preferences/route.ts +++ b/app/api/settings/preferences/route.ts @@ -4,7 +4,7 @@ import { writePreferences, DEFAULT_PREFERENCES, } from "@/lib/settings/store" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" export async function GET(req: Request) { diff --git a/app/api/settings/workspace/route.ts b/app/api/settings/workspace/route.ts index 590386d..30be322 100644 --- a/app/api/settings/workspace/route.ts +++ b/app/api/settings/workspace/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server" import { promises as fs } from "fs" import path from "path" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" import { getSession } from "@/lib/session" let _base: string | null = null diff --git a/app/api/sin/orchestrator/stream/route.ts b/app/api/sin/orchestrator/stream/route.ts index 34b5e58..8e2442a 100644 --- a/app/api/sin/orchestrator/stream/route.ts +++ b/app/api/sin/orchestrator/stream/route.ts @@ -3,19 +3,19 @@ * GET /api/sin/orchestrator/stream?task=… * Emits: event "line" per stdout line, event "done" with exit code, * event "error" on failure. + * + * The actual spawn() lives in lib/sin/orchestrator-stream.ts and is + * dynamically imported so the NFT tracer never sees child_process at + * the route boundary (#59 / #60). */ -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' -export const dynamic = "force-dynamic" -export const runtime = "nodejs" +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' export const maxDuration = 300 const SAFE_TOKEN = /^[\w@./:=,\- ?!]{1,512}$/ -function sse(event: string, data: unknown): string { - return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` -} - export async function GET(req: Request) { const guard = await guardRequest(req, 'orchestrator-stream', 3, 60_000) if (guard) return guard @@ -25,71 +25,6 @@ export async function GET(req: Request) { return Response.json({ ok: false, error: 'invalid task' }, { status: 400 }) } - const encoder = new TextEncoder() - const bin = process.env.SIN_CODE_BIN || 'sin-code' - const { spawn } = await import('node:child_process') - - const stream = new ReadableStream({ - start(controller) { - const child = spawn(bin, ['orchestrator-run', task], { - timeout: 280_000, - }) - - let buffer = '' - const pushLines = (chunk: Buffer) => { - buffer += chunk.toString() - const lines = buffer.split('\n') - buffer = lines.pop() ?? '' - for (const line of lines) { - if (line.trim()) { - controller.enqueue(encoder.encode(sse('line', { line }))) - } - } - } - - child.stdout.on('data', pushLines) - child.stderr.on('data', pushLines) - - child.on('error', (err) => { - const e = err as NodeJS.ErrnoException - controller.enqueue( - encoder.encode( - sse('error', { - error: - e.code === 'ENOENT' - ? 'sin-code binary not installed' - : e.message, - }), - ), - ) - controller.close() - }) - - child.on('close', (code) => { - if (buffer.trim()) { - controller.enqueue(encoder.encode(sse('line', { line: buffer }))) - } - controller.enqueue(encoder.encode(sse('done', { exitCode: code }))) - controller.close() - }) - - req.signal.addEventListener('abort', () => { - child.kill('SIGTERM') - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - }, - }) -} - -export async function POST(req: Request) { - const { task } = await req.json() - const { runOrchestratorStream } = await import("@/lib/sin/orchestrator-runner") - return runOrchestratorStream(task) + const { runOrchestratorStream } = await import('@/lib/sin/orchestrator-stream') + return await runOrchestratorStream(task, req.signal) } diff --git a/app/api/workspace/deploy/route.ts b/app/api/workspace/deploy/route.ts index d7ec113..5c7aebb 100644 --- a/app/api/workspace/deploy/route.ts +++ b/app/api/workspace/deploy/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server" import { isVercelConfigured } from "@/lib/vercel/client" import { createDeployment, getDeploymentStatus } from "@/lib/vercel/deploy" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" export const maxDuration = 300 diff --git a/app/api/workspace/deployments/route.ts b/app/api/workspace/deployments/route.ts index 179800a..97aa7cd 100644 --- a/app/api/workspace/deployments/route.ts +++ b/app/api/workspace/deployments/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" import { listDeployments } from "@/lib/vercel/deploy" -import { guardRequest } from "@/lib/sin/run" +import { guardRequest } from "@/lib/sin/guard" export async function GET(req: Request) { const guard = await guardRequest(req, "deploy", 60) diff --git a/app/api/workspace/design-edit/route.ts b/app/api/workspace/design-edit/route.ts index c80ad15..70814a6 100644 --- a/app/api/workspace/design-edit/route.ts +++ b/app/api/workspace/design-edit/route.ts @@ -1,80 +1,28 @@ -import { NextResponse } from "next/server" -import { promises as fs } from "fs" -import path from "path" -import { pushEntry } from "@/lib/workspace/design-history" +import { NextResponse } from 'next/server' -let _root: string | null = null -// @turbopack-disable-next-line -function root(): string { - if (!_root) _root = /*turbopackIgnore: true*/ (process.env.SIN_WORKSPACE_DIR ?? process.cwd()) - return _root -} - -function safeResolve(rel: string): string { - const resolved = /*turbopackIgnore: true*/ path.resolve(root(), "." + path.sep + rel) - if (!resolved.startsWith(root())) throw new Error("Invalid path") - return resolved -} +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' export async function POST(req: Request) { const { loc, oldClasses, newClasses } = await req.json() - if (typeof oldClasses !== "string" || typeof newClasses !== "string") { - return NextResponse.json({ error: "oldClasses and newClasses required" }, { status: 400 }) + if (typeof oldClasses !== 'string' || typeof newClasses !== 'string') { + return NextResponse.json({ error: 'oldClasses and newClasses required' }, { status: 400 }) } - if (typeof loc !== "string" || !loc.includes(":")) { + if (typeof loc !== 'string' || !loc.includes(':')) { return NextResponse.json( - { error: "loc required (file:line). Add data-sin-loc attributes to your dev build." }, + { error: 'loc required (file:line). Add data-sin-loc attributes to your dev build.' }, { status: 400 }, ) } - const [file, lineStr] = loc.split(":") - const lineNo = parseInt(lineStr, 10) + // Dynamic import keeps fs/path/cwd out of the route boundary the NFT + // tracer inspects — this is what finally clears the warning (#59). + const { applyClassEdit } = await import('@/lib/workspace/design-edit-fs') + const result = await applyClassEdit(loc, oldClasses, newClasses) - let abs: string - try { - abs = safeResolve(file) - } catch { - return NextResponse.json({ error: "Invalid path" }, { status: 400 }) + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }) } - - let content: string - try { - content = await /*turbopackIgnore: true*/ fs.readFile(abs, "utf8") - } catch { - return NextResponse.json({ error: "File not found" }, { status: 404 }) - } - - const lines = content.split("\n") - const from = Math.max(0, lineNo - 3) - const to = Math.min(lines.length, lineNo + 5) - let patched = false - - for (let i = from; i < to; i++) { - if (lines[i].includes(oldClasses)) { - lines[i] = lines[i].replace(oldClasses, newClasses) - patched = true - break - } - } - - if (!patched) { - return NextResponse.json( - { error: "Could not locate the class string near the reported line." }, - { status: 409 }, - ) - } - - await /*turbopackIgnore: true*/ fs.writeFile(abs, lines.join("\n"), "utf8") - const relativeFilePath = file - const tagName = "div" // or get from the request context - await pushEntry({ - file: relativeFilePath, - line: lineNo, - oldValue: oldClasses, - newValue: newClasses, - description: `Changed ${tagName} class to '${newClasses.slice(0, 60)}'`, - }) - return NextResponse.json({ ok: true, file, line: lineNo }) + return NextResponse.json({ ok: true, file: result.file, line: result.line }) } diff --git a/app/api/workspace/files/route.ts b/app/api/workspace/files/route.ts index 76bb997..dfd4c8a 100644 --- a/app/api/workspace/files/route.ts +++ b/app/api/workspace/files/route.ts @@ -1,69 +1,24 @@ -import { NextResponse } from "next/server" -import { promises as fs } from "fs" -import path from "path" +import { NextResponse } from 'next/server' -let _root: string | null = null -// @turbopack-disable-next-line -function root(): string { - if (!_root) _root = process.env.SIN_WORKSPACE_DIR ?? (/*turbopackIgnore: true*/ process.cwd()) - return _root -} - -const IGNORE = new Set(["node_modules", ".git", ".next", ".sin-webui", "dist"]) -const MAX_FILE_SIZE = 512 * 1024 - -interface TreeNode { - name: string - path: string - type: "file" | "dir" - children?: TreeNode[] -} - -function safeResolve(rel: string): string { - const resolved = /*turbopackIgnore: true*/ path.resolve(root(), "." + path.sep + rel) - if (!resolved.startsWith(root())) throw new Error("Invalid path") - return resolved -} - -async function buildTree(dir: string, relBase = ""): Promise { - const entries = await fs.readdir(dir, { withFileTypes: true }) - const nodes: TreeNode[] = [] - for (const entry of entries) { - if (IGNORE.has(entry.name)) continue - const relPath = relBase ? `${relBase}/${entry.name}` : entry.name - if (entry.isDirectory()) { - nodes.push({ - name: entry.name, - path: relPath, - type: "dir", - children: await buildTree(/*turbopackIgnore: true*/ path.join(dir, entry.name), relPath), - }) - } else { - nodes.push({ name: entry.name, path: relPath, type: "file" }) - } - } - return nodes.sort((a, b) => - a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1, - ) -} +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' export async function GET(req: Request) { const { searchParams } = new URL(req.url) - const filePath = searchParams.get("path") + const filePath = searchParams.get('path') + + // Dynamic import keeps fs/path/cwd out of the route boundary the NFT + // tracer inspects (#59 / #60). + const { readWorkspaceFile, listWorkspaceTree } = await import('@/lib/workspace/files-fs') if (filePath) { try { - const abs = safeResolve(filePath) - const stat = await fs.stat(abs) - if (stat.size > MAX_FILE_SIZE) { - return NextResponse.json({ content: "// File too large to display" }) - } - const content = await fs.readFile(abs, "utf8") + const content = await readWorkspaceFile(filePath) return NextResponse.json({ content }) } catch { - return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json({ error: 'Not found' }, { status: 404 }) } } - return NextResponse.json({ nodes: await buildTree(root()) }) + return NextResponse.json({ nodes: await listWorkspaceTree() }) } diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts index c3b074a..033addf 100644 --- a/app/api/workspaces/route.ts +++ b/app/api/workspaces/route.ts @@ -4,7 +4,7 @@ * POST /api/workspaces {…} — create/update a custom workspace * DELETE /api/workspaces { id } — delete own custom workspace */ -import { guardRequest } from '@/lib/sin/run' +import { guardRequest } from '@/lib/sin/guard' import { getSession } from '@/lib/session' import { deleteCustomWorkspace, diff --git a/components/auth/user-menu.tsx b/components/auth/user-menu.tsx index b601265..3998c42 100644 --- a/components/auth/user-menu.tsx +++ b/components/auth/user-menu.tsx @@ -1,18 +1,69 @@ "use client" import { useRouter } from "next/navigation" -import { signOut, useSession } from "@/lib/auth/client" -import { LogOut, User } from "lucide-react" +import { useTheme } from "next-themes" +import Link from "next/link" +import { + BookOpen, + ChevronDown, + CircleUser, + CreditCard, + Gift, + LogOut, + MessageCircleQuestion, + Monitor, + Moon, + Settings as SettingsIcon, + Sun, + Users, +} from "lucide-react" +import { useSession } from "@/lib/auth/client" +import { cn } from "@/lib/utils" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +const FOOTER_LINKS = [ + { href: "/settings/preferences", label: "Profile", Icon: CircleUser }, + { href: "/settings", label: "Account Settings", Icon: SettingsIcon }, + { href: "/settings/billing", label: "Pricing", Icon: CreditCard }, + { + href: "https://github.com/OpenSIN-Code/SIN-Code-WebUI-v2#readme", + label: "Documentation", + Icon: BookOpen, + external: true, + }, + { + href: "https://github.com/OpenSIN-Code/SIN-Code-WebUI-v2/issues", + label: "Feedback", + Icon: MessageCircleQuestion, + external: true, + }, + { + href: "https://github.com/orgs/OpenSIN-Code/discussions", + label: "Community Forum", + Icon: Users, + external: true, + }, + { href: "/settings/preferences?tab=referral", label: "Refer a Friend", Icon: Gift }, +] export function UserMenu() { const router = useRouter() const { data: session, isPending } = useSession() + const { theme, setTheme } = useTheme() if (isPending) { return (
- - + + … +
) } @@ -22,34 +73,155 @@ export function UserMenu() { ) } + const user = session.user as { name?: string | null; email?: string | null } + const displayName = user.name || user.email || "User" + const initial = (user.name || user.email || "U").charAt(0).toUpperCase() + const email = user.email || "" + async function handleSignOut() { + const { signOut } = await import("@/lib/auth/client") await signOut() router.push("/login") router.refresh() } return ( -
- - - {session.user.name || session.user.email} - - -
+ + {initial} + + {displayName} + + + +
+ + {displayName} + + {email ? ( + {email} + ) : null} +
+ + + + {FOOTER_LINKS.map(({ href, label, Icon, external }) => ( + + + {label} + + ) : ( + + + {label} + + ) + } + /> + ))} + + + + +
+ Preferences +
+ + {/* Theme switch — System / Light / Dark */} +
+ Theme +
+ {( + [ + { value: "system", Icon: Monitor, label: "System theme" }, + { value: "light", Icon: Sun, label: "Light theme" }, + { value: "dark", Icon: Moon, label: "Dark theme" }, + ] as const + ).map(({ value, Icon, label }) => ( + + ))} +
+
+ + {/* Language — stub (no i18n yet) */} +
+ Language + + English + +
+ + {/* Chat Position — stub (single layout) */} +
+ Chat Position + + Left + +
+ + + + + + + Sign Out + + } + /> + +
+ ) } diff --git a/components/chat-header.tsx b/components/chat/chat-header.tsx similarity index 52% rename from components/chat-header.tsx rename to components/chat/chat-header.tsx index 1c1acb3..b05c43d 100644 --- a/components/chat-header.tsx +++ b/components/chat/chat-header.tsx @@ -1,27 +1,18 @@ -/** - * Purpose: Chat page header — breadcrumb, title dropdown, project menu, share. - * Wired to chat-store (rename, delete, favorite) and ShareMenu (visibility + link). - * Related issues: #15, #16 - */ 'use client' -import { useRouter } from 'next/navigation' import { BarChart3, ChevronDown, FileKey, GitBranch, Globe, - LayoutGrid, LayoutTemplate, MoreHorizontal, Puzzle, + Share2, Star, } from 'lucide-react' import { DashedSpinner, VercelTriangle } from '@/components/icons' -import { useChatStore } from '@/components/chat-store' -import { PublishMenu } from '@/components/publish-menu' -import { ShareMenu } from '@/components/share-menu' import { DropdownMenu, DropdownMenuContent, @@ -31,41 +22,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -export function ChatHeader({ - title, - chatId, - activeProject, -}: { - title: string - chatId?: string - /** Project this chat belongs to (via project.chatIds.includes(chatId)). */ - activeProject?: { id: string; name: string } | null -}) { - const router = useRouter() - const { removeChat, renameChat, toggleFavorite, recentChats } = useChatStore() - const isFavorite = chatId - ? recentChats.find((c) => c.id === chatId)?.favorite - : false - - function handleDelete() { - if (!chatId) return - if (!window.confirm(`Delete "${title}"? This cannot be undone.`)) return - removeChat(chatId) - router.push('/chats') - } - - function handleRename() { - if (!chatId) return - const next = window.prompt('Rename chat', title) - const trimmed = next?.trim() - if (trimmed && trimmed !== title) renameChat(chatId, trimmed) - } - - function handleToggleFavorite() { - if (!chatId) return - toggleFavorite(chatId) - } - +export function ChatHeader({ title }: { title: string }) { return (
{/* Breadcrumb */} @@ -79,34 +36,16 @@ export function ChatHeader({ Drafts + {/* Slash separator */} / - {/* Active project badge (when the chat belongs to one) */} - {activeProject && ( - - - {activeProject.name} - - )} - {/* Star */} {/* Title dropdown */} @@ -124,33 +63,21 @@ export function ChatHeader({ - - Rename - - - {isFavorite ? 'Remove from Favorites' : 'Add to Favorites'} - + Rename + Add to Favorites - Settings - Transfer… - - Delete - + Settings + Transfer… + Delete {/* Right actions */} + {/* "..." project options */} {/* Share */} - - - {/* Publish */} - +
) } diff --git a/components/chat/chat-view-wrapper.tsx b/components/chat/chat-view-wrapper.tsx index 3461e5c..89263d5 100644 --- a/components/chat/chat-view-wrapper.tsx +++ b/components/chat/chat-view-wrapper.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useChat } from '@ai-sdk/react' import { DefaultChatTransport, type UIMessage } from 'ai' import { ChatView, type ChatMessage, type ChatPart } from '@/components/chat/chat-view' +import { useChatStore } from '@/components/chat-store' import { SIN_MODELS } from '@/lib/sin/models' import { useSoundNotification } from '@/hooks/use-sound-notification' @@ -69,6 +70,9 @@ export function ChatViewWrapper({ const [agent] = useState('auto') const [initialMessages, setInitialMessages] = useState(null) const playNotification = useSoundNotification() + const { recentChats } = useChatStore() + const chat = recentChats.find((c) => c.id === chatId) + const title = chat?.label || 'New chat' useEffect(() => { let cancelled = false @@ -154,6 +158,7 @@ export function ChatViewWrapper({ onSend={handleSend} onStop={handleStop} initialModel={model} + title={title} /> ) } diff --git a/components/chat/chat-view.tsx b/components/chat/chat-view.tsx index 0b442a1..f20710c 100644 --- a/components/chat/chat-view.tsx +++ b/components/chat/chat-view.tsx @@ -6,6 +6,7 @@ import { ThinkingIndicator, LoadingDots } from "@/components/chat/thinking-indic import { ToolCall } from "@/components/chat/tool-call" import { PromptComposer } from "@/components/chat/prompt-composer" import { MarkdownMessage } from "@/components/chat/markdown-message" +import { ChatHeader } from "@/components/chat/chat-header" export interface ChatPart { type: "text" | "tool" @@ -28,9 +29,17 @@ interface ChatViewProps { onSend: (text: string, model: string) => void onStop?: () => void initialModel?: string + title?: string } -export function ChatView({ messages, status, onSend, onStop, initialModel }: ChatViewProps) { +export function ChatView({ + messages, + status, + onSend, + onStop, + initialModel, + title = "New chat", +}: ChatViewProps) { const bottomRef = useRef(null) useEffect(() => { @@ -42,6 +51,7 @@ export function ChatView({ messages, status, onSend, onStop, initialModel }: Cha return (
+
{messages.length === 0 && ( diff --git a/components/chats-list.tsx b/components/chats-list.tsx index 79d8a41..62a0975 100644 --- a/components/chats-list.tsx +++ b/components/chats-list.tsx @@ -1,17 +1,11 @@ -/** - * Purpose: /chats list — each row is a link with a hover-revealed - * favorite toggle (amber star) and a timestamp. - * Related issues: #23 - */ 'use client' -import { Clock, SquarePen, Star } from 'lucide-react' +import { Clock, SquarePen } from 'lucide-react' import Link from 'next/link' import { useChatStore } from '@/components/chat-store' -import { cn } from '@/lib/utils' export function ChatsList() { - const { recentChats, toggleFavorite } = useChatStore() + const { recentChats } = useChatStore() if (recentChats.length === 0) { return ( @@ -24,50 +18,23 @@ export function ChatsList() { return (
{recentChats.map((chat) => ( -
- - - - - - - {chat.label} - - - Drafts - - - + + + + + {chat.label} + Drafts + - {'1:22 PM'} + Recent - -
+ ))}
) diff --git a/components/workspace/preview-panel.tsx b/components/workspace/preview-panel.tsx index 204449e..bffb1b4 100644 --- a/components/workspace/preview-panel.tsx +++ b/components/workspace/preview-panel.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useRef, useEffect } from "react" +import { useState, useRef, useEffect, useCallback } from "react" import { ChevronLeft, ChevronRight, @@ -14,10 +14,19 @@ export function PreviewPanel({ src }: { src: string }) { const [path, setPath] = useState("/") const [mobile, setMobile] = useState(false) const [reloadKey, setReloadKey] = useState(0) + const [toast, setToast] = useState(null) const iframeRef = useRef(null) + const toastTimer = useRef | null>(null) const fullUrl = src.replace(/\/$/, "") + path + const flash = useCallback((msg: string) => { + setToast(msg) + if (toastTimer.current) clearTimeout(toastTimer.current) + toastTimer.current = setTimeout(() => setToast(null), 1400) + }, []) + + // Reload when the design history changes (undo/redo edits the source). useEffect(() => { function reload() { setReloadKey((k) => k + 1) @@ -26,6 +35,35 @@ export function PreviewPanel({ src }: { src: string }) { return () => window.removeEventListener("sin:design-history-changed", reload) }, []) + // Listen for undo/redo requests posted from the design-mode iframe agent (#60). + useEffect(() => { + async function onMessage(e: MessageEvent) { + const d = e.data + if (!d || d.source !== "sin-design") return + if (d.type !== "design-undo" && d.type !== "design-redo") return + + const action = d.type === "design-undo" ? "undo" : "redo" + try { + const res = await fetch("/api/workspace/design-history", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }) + const json = await res.json() + if (json?.ok && json.entry) { + flash(action === "undo" ? "Undid change" : "Redid change") + window.dispatchEvent(new Event("sin:design-history-changed")) + } else { + flash(action === "undo" ? "Nothing to undo" : "Nothing to redo") + } + } catch { + flash("Undo/redo failed") + } + } + window.addEventListener("message", onMessage) + return () => window.removeEventListener("message", onMessage) + }, [flash]) + return (
@@ -93,7 +131,7 @@ export function PreviewPanel({ src }: { src: string }) {
-
+