2868 lines
94 KiB
TypeScript
2868 lines
94 KiB
TypeScript
"use client";
|
||
|
||
import React, {
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
useCallback,
|
||
type ReactNode,
|
||
type CSSProperties,
|
||
} from "react";
|
||
import Link from "next/link";
|
||
import { useSession } from "next-auth/react";
|
||
import { useParams, usePathname } from "next/navigation";
|
||
import {
|
||
MessageSquare,
|
||
X,
|
||
ChevronRight,
|
||
Send,
|
||
Plus,
|
||
Loader2,
|
||
Wrench,
|
||
ChevronDown,
|
||
Trash2,
|
||
Square,
|
||
MousePointerClick,
|
||
Paperclip,
|
||
ChevronLeft,
|
||
} from "lucide-react";
|
||
import { ProjectIconRail } from "@/components/project/project-icon-rail";
|
||
import {
|
||
PreviewBridgeProvider,
|
||
previewMessagePrepRef,
|
||
usePreviewBridge,
|
||
} from "@/components/project/preview-bridge-context";
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface Thread {
|
||
id: string;
|
||
title: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
interface Message {
|
||
id?: string;
|
||
role: "user" | "assistant" | "tool";
|
||
content: string;
|
||
toolCalls?: { id: string; name: string; args: Record<string, unknown> }[];
|
||
toolName?: string;
|
||
createdAt?: string;
|
||
/**
|
||
* Chronological turn timeline interleaving the model's thinking
|
||
* narration and the tool calls it fired. Rendered as a stack of
|
||
* pills INSIDE the bubble above the final text content, so the
|
||
* user sees the actual flow:
|
||
* [thought] [tool ×N] [thought] [tool] ... [summary]
|
||
* Each thought is its own collapsed pill (click to expand);
|
||
* adjacent runs of the same tool name collapse into one pill
|
||
* with a ×N counter. The final assistant text is rendered
|
||
* separately, below the timeline.
|
||
*/
|
||
timeline?: TimelineEntry[];
|
||
}
|
||
|
||
type TimelineEntry =
|
||
| { kind: "thought"; text: string }
|
||
| { kind: "phase"; phase: string; label: string }
|
||
| {
|
||
kind: "tool";
|
||
name: string;
|
||
status: "running" | "done" | "error";
|
||
result?: string;
|
||
args?: Record<string, unknown>;
|
||
}
|
||
// A text segment from one round of the assistant's tool loop.
|
||
// Each text SSE event from the server starts a new entry; subsequent
|
||
// streaming chunks for that same round append to the most-recent
|
||
// text entry. Tool/thought entries between text segments break the
|
||
// accumulation so multi-round turns render as separate bubbles.
|
||
| { kind: "text"; text: string };
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function getFriendlyCategory(name: string): string {
|
||
const dotted = name.replace(/_/g, ".");
|
||
if (dotted.startsWith("databases.")) return "Provisioning database service";
|
||
if (dotted.startsWith("domains."))
|
||
return "Registering & attaching custom domains";
|
||
if (dotted.startsWith("apps.envs."))
|
||
return "Updating configuration environment";
|
||
if (dotted.startsWith("apps.")) return "Deploying web application container";
|
||
if (dotted.startsWith("gitea.")) return "Configuring Git repositories";
|
||
if (dotted.startsWith("devcontainer."))
|
||
return "Spinning up secure Alpine environment";
|
||
if (dotted.startsWith("plan."))
|
||
return "Structuring development roadmap & specs";
|
||
|
||
if (
|
||
name.includes("fs.edit") ||
|
||
name.includes("fs.write") ||
|
||
name.includes("fs_edit") ||
|
||
name.includes("fs_write")
|
||
)
|
||
return "Surgically writing & editing code";
|
||
if (
|
||
name.includes("fs.read") ||
|
||
name.includes("fs.list") ||
|
||
name.includes("fs.grep") ||
|
||
name.includes("fs.tree") ||
|
||
name.includes("fs_read") ||
|
||
name.includes("fs_list") ||
|
||
name.includes("fs_grep") ||
|
||
name.includes("fs_tree")
|
||
)
|
||
return "Scanning & reading project files";
|
||
if (name.includes("shell.exec") || name.includes("shell_exec"))
|
||
return "Executing sandbox terminal scripts";
|
||
if (name.includes("dev_server.start") || name.includes("dev_server_start"))
|
||
return "Initializing local dev-server";
|
||
if (name.includes("dev_server.logs") || name.includes("dev_server_logs"))
|
||
return "Analyzing compilation build logs";
|
||
if (
|
||
name.includes("browser.navigate") ||
|
||
name.includes("browser.console") ||
|
||
name.includes("browser_navigate") ||
|
||
name.includes("browser_console")
|
||
)
|
||
return "Performing live design QA & console audits";
|
||
if (name.includes("request_visual_qa") || name.includes("request.visual.qa"))
|
||
return "Running world-class visual design QA";
|
||
if (name.includes("ship")) return "Bundling & shipping live release";
|
||
|
||
return name;
|
||
}
|
||
|
||
function timeAgo(dateStr?: string): string {
|
||
if (!dateStr) return "";
|
||
const diff = (Date.now() - new Date(dateStr).getTime()) / 1000;
|
||
if (diff < 60) return "just now";
|
||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||
return `${Math.floor(diff / 86400)}d ago`;
|
||
}
|
||
|
||
function friendlyToolName(name: string): string {
|
||
const dotted = name.replace(/_/g, ".");
|
||
|
||
const map: Record<string, string> = {
|
||
// Filesystem Tools
|
||
"fs.read": "Reading file",
|
||
"fs.write": "Writing file",
|
||
"fs.edit": "Editing code",
|
||
"fs.list": "Listing folder",
|
||
"fs.tree": "Mapping structure",
|
||
"fs.delete": "Deleting file",
|
||
"fs.glob": "Finding files",
|
||
"fs.grep": "Searching code",
|
||
|
||
// Server & Browser Tools
|
||
"dev.server.start": "Starting server",
|
||
"dev.server.stop": "Stopping server",
|
||
"dev.server.list": "Checking servers",
|
||
"dev.server.logs": "Reading logs",
|
||
"browser.console": "Checking console",
|
||
"browser.navigate": "Testing page",
|
||
"request.visual.qa": "Running visual QA",
|
||
|
||
// Core Platform Tools
|
||
"projects.list": "Listing workspace projects",
|
||
"projects.get": "Retrieving project spec sheets",
|
||
"workspace.describe": "Fetching workspace details",
|
||
"gitea.credentials": "Resolving repository Git credentials",
|
||
"shell.exec": "Running command",
|
||
ship: "Shipping",
|
||
"generate.media": "Generating visual media assets",
|
||
"get.design.template": "Retrieving design templates",
|
||
"apps.templates.scaffold": "Scaffolding bento-grid layouts",
|
||
|
||
// App & Database Deployment Tools
|
||
"apps.list": "Listing deployed web services",
|
||
"apps.get": "Checking application build status",
|
||
"apps.create": "Creating app",
|
||
"apps.update": "Updating application settings",
|
||
"apps.delete": "Removing deployed application",
|
||
"apps.deploy": "Deploying app",
|
||
"apps.deployments": "Fetching recent deployment history",
|
||
"apps.envs.list": "Loading environment variables",
|
||
"apps.envs.upsert": "Injecting environment variables",
|
||
"apps.envs.delete": "Removing environment variables",
|
||
"apps.domains.list": "Checking application domain routing",
|
||
"apps.domains.set": "Binding custom domains",
|
||
"apps.logs": "Fetching application logs",
|
||
"apps.exec": "Running command inside container",
|
||
"databases.list": "Listing database clusters",
|
||
"databases.create": "Provisioning database service",
|
||
"databases.get": "Retrieving database connection credentials",
|
||
"databases.update": "Updating database configuration",
|
||
"databases.delete": "Removing database service",
|
||
|
||
// Domain & Git Tools
|
||
"domains.search": "Searching open domain names",
|
||
"domains.list": "Listing registered domains",
|
||
"domains.get": "Retrieving domain details",
|
||
"domains.register": "Registering domain name",
|
||
"domains.attach": "Attaching domain reverse-proxy rules",
|
||
"gitea.repos.list": "Listing Gitea repositories",
|
||
"gitea.repo.get": "Loading Gitea repository info",
|
||
"gitea.repo.create": "Initializing Gitea repository",
|
||
"gitea.file.read": "Reading file from Gitea",
|
||
"gitea.file.write": "Saving file to Gitea",
|
||
"gitea.file.delete": "Deleting file from Gitea",
|
||
"gitea.branches.list": "Checking repository branches",
|
||
"gitea.branch.create": "Creating Git branch",
|
||
"devcontainer.ensure": "Spinning up secure Alpine dev container",
|
||
"devcontainer.status": "Probing dev container liveness",
|
||
"devcontainer.suspend": "Suspending dev container",
|
||
|
||
// Planning / Specs Tools
|
||
"plan.get": "Loading specifications checklist",
|
||
"plan.vision.set": "Saving feature product specification",
|
||
"plan.idea.add": "Adding planning ideation",
|
||
"plan.task.add": "Adding task to development roadmap",
|
||
"plan.task.edit": "Updating development roadmap task",
|
||
"plan.task.complete": "Toggling checklist milestone as completed",
|
||
"plan.document.update": "Updating specs documentation",
|
||
};
|
||
|
||
return map[dotted] || dotted;
|
||
}
|
||
|
||
/**
|
||
* Turn a raw tool result string (often a JSON blob like
|
||
* `{ "code": 1, "stdout": "...", "stderr": "..." }`) into a short,
|
||
* human-readable status. We NEVER show raw JSON in the chat — only a
|
||
* clean verb + outcome (e.g. "Failed — exit 1: connection refused").
|
||
*/
|
||
function summarizeToolResult(result?: string): {
|
||
ok: boolean;
|
||
label: string;
|
||
} | null {
|
||
if (!result) return null;
|
||
const raw = String(result).trim();
|
||
if (!raw) return null;
|
||
|
||
let parsed: Record<string, unknown> | null = null;
|
||
try {
|
||
const p = JSON.parse(raw);
|
||
if (p && typeof p === "object") parsed = p as Record<string, unknown>;
|
||
} catch {
|
||
// not JSON — fall through to text heuristics
|
||
}
|
||
|
||
const firstLine = (s: string) => s.replace(/\s+/g, " ").trim().slice(0, 80);
|
||
|
||
if (parsed) {
|
||
const code = parsed.code;
|
||
const stderr = typeof parsed.stderr === "string" ? parsed.stderr : "";
|
||
const stdout = typeof parsed.stdout === "string" ? parsed.stdout : "";
|
||
const errMsg = typeof parsed.error === "string" ? parsed.error : "";
|
||
|
||
// Explicit non-zero exit code → failure
|
||
if (typeof code === "number" && code !== 0) {
|
||
const detail = firstLine(stderr || errMsg || stdout);
|
||
return {
|
||
ok: false,
|
||
label: detail
|
||
? `Failed (exit ${code}) — ${detail}`
|
||
: `Failed (exit ${code})`,
|
||
};
|
||
}
|
||
if (errMsg && !/^null$/i.test(errMsg)) {
|
||
return { ok: false, label: `Failed — ${firstLine(errMsg)}` };
|
||
}
|
||
if (parsed.ok === false) {
|
||
return {
|
||
ok: false,
|
||
label: `Failed${stderr ? " — " + firstLine(stderr) : ""}`,
|
||
};
|
||
}
|
||
// Success-ish: surface a tiny hint when available, else just "Done"
|
||
if (typeof code === "number" && code === 0)
|
||
return { ok: true, label: "Done" };
|
||
return { ok: true, label: "Done" };
|
||
}
|
||
|
||
// Plain-text heuristics
|
||
const lower = raw.toLowerCase();
|
||
if (
|
||
/(econnrefused|enoent|error|failed|traceback|exception|not found|permission denied|cannot)/.test(
|
||
lower,
|
||
)
|
||
) {
|
||
return { ok: false, label: `Failed — ${firstLine(raw)}` };
|
||
}
|
||
return { ok: true, label: "Done" };
|
||
}
|
||
|
||
// ── Markdown-lite renderer ────────────────────────────────────────────────────
|
||
|
||
function escapeHtmlAttr(s: string): string {
|
||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||
}
|
||
|
||
const LINK_STYLE =
|
||
"color:#4338ca;text-decoration:underline;text-underline-offset:2px;overflow-wrap:anywhere;word-break:break-all";
|
||
|
||
/** [label](https://...) — href restricted to http(s) */
|
||
function markdownLinksToHtml(s: string): string {
|
||
return s.replace(
|
||
/\[([^\]]+)\]\((https?:\/\/[^\s)<>]+)\)/gi,
|
||
(_m, label: string, url: string) => {
|
||
return `<a href="${escapeHtmlAttr(url)}" target="_blank" rel="noopener noreferrer" style="${LINK_STYLE}">${label}</a>`;
|
||
},
|
||
);
|
||
}
|
||
|
||
/** Bare https:// in prose (skips when prefix is `>` so href=/code aren't touched) */
|
||
function autoLinkBareUrls(s: string): string {
|
||
return s.replace(
|
||
/(^|[\s\-—:(\[{])(https?:\/\/[^\s<>"']+)/gi,
|
||
(match, pre: string, url: string) =>
|
||
`${pre}<a href="${escapeHtmlAttr(url)}" target="_blank" rel="noopener noreferrer" style="${LINK_STYLE}">${url}</a>`,
|
||
);
|
||
}
|
||
|
||
function renderMarkdown(text: string): string {
|
||
const codeBlocks: string[] = [];
|
||
let s = text;
|
||
|
||
// Extract triple backtick code blocks first to protect them from other formatting
|
||
s = s.replace(/```(\w*)[\s\n]([\s\S]*?)```/g, (match, lang, code) => {
|
||
const id = `___CODE_BLOCK_${codeBlocks.length}___`;
|
||
const escapedCode = code
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
const languageLabel = lang ? lang.toUpperCase() : "CODE";
|
||
|
||
const containerStyle = `
|
||
margin: 12px 0;
|
||
border: 1px solid #e4e4e7;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
background: #fafafa;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.02);
|
||
font-family: var(--font-ibm-plex-mono), SFMono-Regular, Consolas, monospace;
|
||
`
|
||
.trim()
|
||
.replace(/\s+/g, " ");
|
||
|
||
const headerStyle = `
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 6px 12px;
|
||
background: #f4f4f5;
|
||
border-bottom: 1px solid #e4e4e7;
|
||
font-size: 0.68rem;
|
||
font-weight: 600;
|
||
color: #52525b;
|
||
`
|
||
.trim()
|
||
.replace(/\s+/g, " ");
|
||
|
||
const preStyle = `
|
||
display: block;
|
||
padding: 12px;
|
||
margin: 0;
|
||
overflow-x: auto;
|
||
font-size: 0.78rem;
|
||
line-height: 1.55;
|
||
color: #1a1a1a;
|
||
white-space: pre;
|
||
`
|
||
.trim()
|
||
.replace(/\s+/g, " ");
|
||
|
||
const buttonStyle = `
|
||
background: #ffffff;
|
||
border: 1px solid #e4e4e7;
|
||
border-radius: 4px;
|
||
padding: 2px 6px;
|
||
font-size: 0.65rem;
|
||
cursor: pointer;
|
||
color: #52525b;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-family: var(--font-inter), ui-sans-serif, sans-serif;
|
||
transition: all 0.1s ease;
|
||
`
|
||
.trim()
|
||
.replace(/\s+/g, " ");
|
||
|
||
const copyId = `btn-copy-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||
const textId = `txt-code-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||
|
||
const copyScript = `
|
||
navigator.clipboard.writeText(document.getElementById('${textId}').innerText)
|
||
.then(() => {
|
||
const btn = document.getElementById('${copyId}');
|
||
btn.innerHTML = 'Copied ✓';
|
||
setTimeout(() => { btn.innerHTML = 'Copy'; }, 1500);
|
||
});
|
||
`
|
||
.trim()
|
||
.replace(/\s+/g, " ");
|
||
|
||
const blockHtml = `
|
||
<div style="${containerStyle}">
|
||
<div style="${headerStyle}">
|
||
<span>${languageLabel}</span>
|
||
<button id="${copyId}" onclick="${copyScript}" style="${buttonStyle}">Copy</button>
|
||
</div>
|
||
<pre id="${textId}" style="${preStyle}"><code>${escapedCode}</code></pre>
|
||
</div>
|
||
`
|
||
.trim()
|
||
.replace(/\s*\n\s*/g, "");
|
||
|
||
codeBlocks.push(blockHtml);
|
||
return id;
|
||
});
|
||
|
||
// Safe escape of remaining content
|
||
s = s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
|
||
s = markdownLinksToHtml(s);
|
||
|
||
s = s
|
||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||
.replace(
|
||
/`([^`]+)`/g,
|
||
'<code style="background:#f4f4f5;padding:1px 5px;border-radius:3px;font-family:var(--font-ibm-plex-mono),monospace;font-size:0.85em;overflow-wrap:anywhere;word-break:break-word">$1</code>',
|
||
)
|
||
.replace(
|
||
/^### (.+)$/gm,
|
||
'<h3 style="font-weight:600;margin:12px 0 4px;font-size:0.88rem;overflow-wrap:anywhere;word-break:break-word">$1</h3>',
|
||
)
|
||
.replace(
|
||
/^## (.+)$/gm,
|
||
'<h2 style="font-weight:600;margin:14px 0 4px;font-size:0.9rem;overflow-wrap:anywhere;word-break:break-word">$1</h2>',
|
||
)
|
||
.replace(
|
||
/^- (.+)$/gm,
|
||
'<li style="margin-left:16px;list-style:disc;overflow-wrap:anywhere;word-break:break-word">$1</li>',
|
||
)
|
||
.replace(
|
||
/(<li[^>]*>.*<\/li>\n?)+/g,
|
||
(m) => `<ul style="margin:6px 0">${m}</ul>`,
|
||
)
|
||
.replace(
|
||
/\n\n/g,
|
||
'</p><p style="margin:0 0 8px;overflow-wrap:anywhere;word-break:break-word">',
|
||
)
|
||
.replace(/\n/g, "<br>");
|
||
|
||
s = autoLinkBareUrls(s);
|
||
|
||
// Restore the formatted code blocks
|
||
codeBlocks.forEach((html, index) => {
|
||
s = s.replace(`___CODE_BLOCK_${index}___`, html);
|
||
});
|
||
|
||
return s;
|
||
}
|
||
|
||
// ── Message bubble ─────────────────────────────────────────────────────────────
|
||
|
||
// NOTE: the model's raw reasoning ("thinking") is intentionally NOT rendered in
|
||
// the chat — `thought` timeline entries are dropped at render time. We keep the
|
||
// per-round narration (the model's plain "I am editing X…" text), which is
|
||
// shown as normal text. The old collapsible ThinkingBubble was removed when we
|
||
// stopped surfacing reasoning walls.
|
||
|
||
function stripRawToolLogs(text: string): string {
|
||
if (!text) return text;
|
||
let out = text.replace(
|
||
/(?:\r?\n)*\[tools executed this turn:[\s\S]*?\]/g,
|
||
"",
|
||
);
|
||
// Safety net: strip the internal "Phase Checkpoint" planning block
|
||
// (Goal / Current Findings / Suspected Cause / Verification Plan) if it
|
||
// ever reaches a user-facing message. This is loop-control machinery, not
|
||
// something the end user should read. We drop from the heading to the end
|
||
// of that block (until a blank line followed by non-bulleted prose, or EOF).
|
||
out = out.replace(/(?:^|\n)\s*#{0,3}\s*Phase Checkpoint[\s\S]*$/i, "").trim();
|
||
return out.trim();
|
||
}
|
||
|
||
const MessageBubble = React.memo(function MessageBubble({
|
||
msg,
|
||
}: {
|
||
msg: Message;
|
||
}) {
|
||
const isUser = msg.role === "user";
|
||
const proseWrap: React.CSSProperties = {
|
||
overflowWrap: "anywhere",
|
||
wordBreak: "break-word",
|
||
minWidth: 0,
|
||
};
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: isUser ? "flex-end" : "flex-start",
|
||
marginBottom: 12,
|
||
minWidth: 0,
|
||
}}
|
||
>
|
||
{!isUser && (
|
||
<div
|
||
style={{
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: "50%",
|
||
background: "#f4f4f5", // Zinc-100 instead of black
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
marginRight: 8,
|
||
flexShrink: 0,
|
||
marginTop: 2,
|
||
border: "1px solid #e4e4e7",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
color: "#18181b", // Dark gray instead of white
|
||
fontSize: "0.6rem",
|
||
fontWeight: 700,
|
||
fontFamily: "var(--font-lora),serif",
|
||
fontStyle: "italic",
|
||
}}
|
||
>
|
||
V.
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div
|
||
style={{
|
||
maxWidth: "82%",
|
||
minWidth: 0,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
}}
|
||
>
|
||
{!isUser && msg.timeline && msg.timeline.length > 0 && (
|
||
<Timeline entries={msg.timeline} />
|
||
)}
|
||
{/*
|
||
Render the legacy bottom content bubble ONLY when:
|
||
- the message is from the user (their bubble is always the
|
||
content slot), OR
|
||
- the assistant message has no timeline at all (very old
|
||
messages from before timeline existed).
|
||
When the timeline contains text entries the prose is already
|
||
rendered there, and showing it again here would duplicate
|
||
every paragraph below the timeline.
|
||
*/}
|
||
{((msg.content && isUser) ||
|
||
(msg.content &&
|
||
!isUser &&
|
||
(!msg.timeline || msg.timeline.length === 0))) && (
|
||
<div
|
||
style={{
|
||
padding: isUser ? "9px 14px" : "4px 0px",
|
||
borderRadius: isUser ? "14px 14px 4px 14px" : "0",
|
||
background: isUser ? "#111827" : "transparent",
|
||
color: isUser ? "#fff" : "#111827",
|
||
fontSize: "0.84rem",
|
||
lineHeight: 1.6,
|
||
letterSpacing: "-0.01em",
|
||
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
|
||
...proseWrap,
|
||
}}
|
||
>
|
||
{isUser ? (
|
||
<span style={{ whiteSpace: "pre-wrap", ...proseWrap }}>
|
||
{msg.content}
|
||
</span>
|
||
) : (
|
||
<span
|
||
style={proseWrap}
|
||
dangerouslySetInnerHTML={{
|
||
__html: renderMarkdown(stripRawToolLogs(msg.content)),
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
/**
|
||
* Renders the chronological turn timeline: thoughts as their own
|
||
* collapsed pills, tool calls grouped by adjacent runs of the same
|
||
* name with a ×N counter. The flow visually mirrors what actually
|
||
* happened: thought → tools → thought → tools → ... → final summary.
|
||
*/
|
||
function Timeline({ entries }: { entries: TimelineEntry[] }) {
|
||
// Walk the entries and emit a renderable list. Adjacent same-category
|
||
// tool entries get bundled into a TimelineToolGroup; thought and
|
||
// text entries pass through as-is.
|
||
type Item =
|
||
| { kind: "thought"; text: string }
|
||
| { kind: "text"; text: string }
|
||
| { kind: "phase"; phase: string; label: string }
|
||
| {
|
||
kind: "toolGroup";
|
||
category: string;
|
||
entries: Array<Extract<TimelineEntry, { kind: "tool" }>>;
|
||
};
|
||
const items: Item[] = [];
|
||
for (const e of entries) {
|
||
if (e.kind === "thought") {
|
||
items.push({ kind: "thought", text: e.text });
|
||
} else if (e.kind === "text") {
|
||
items.push({ kind: "text", text: e.text });
|
||
} else if (e.kind === "phase") {
|
||
// ignore
|
||
} else {
|
||
const last = items[items.length - 1];
|
||
const category = getFriendlyCategory(e.name);
|
||
if (last && last.kind === "toolGroup" && last.category === category) {
|
||
last.entries.push(e);
|
||
} else {
|
||
items.push({ kind: "toolGroup", category, entries: [e] });
|
||
}
|
||
}
|
||
}
|
||
return (
|
||
<div style={{ marginBottom: 6 }}>
|
||
{items.map((item, i) => {
|
||
if (item.kind === "thought") {
|
||
return (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
fontSize: "0.85rem",
|
||
color: "#6b7280",
|
||
fontStyle: "italic",
|
||
padding: "8px 12px",
|
||
background: "#f9fafb",
|
||
borderLeft: "2px solid #d1d5db",
|
||
marginBottom: 8,
|
||
whiteSpace: "pre-wrap",
|
||
}}
|
||
>
|
||
Thinking: {item.text}
|
||
</div>
|
||
);
|
||
}
|
||
if (item.kind === "text") {
|
||
return <TimelineText key={i} text={item.text} />;
|
||
}
|
||
if (item.kind === "toolGroup") {
|
||
return (
|
||
<TimelineToolGroup
|
||
key={i}
|
||
category={item.category}
|
||
entries={item.entries}
|
||
/>
|
||
);
|
||
}
|
||
return null;
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* One text segment in the assistant's timeline. Rendered as its own
|
||
* bubble so each round of multi-tool-loop output reads as a discrete
|
||
* step instead of concatenating into a wall of text.
|
||
*/
|
||
function TimelineText({ text }: { text: string }) {
|
||
const proseWrap: React.CSSProperties = {
|
||
overflowWrap: "anywhere",
|
||
wordBreak: "break-word",
|
||
minWidth: 0,
|
||
};
|
||
return (
|
||
<div
|
||
style={{
|
||
padding: "4px 0px",
|
||
borderRadius: "0",
|
||
background: "transparent",
|
||
color: "#111827",
|
||
fontSize: "0.84rem",
|
||
lineHeight: 1.6,
|
||
letterSpacing: "-0.01em",
|
||
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
|
||
marginBottom: 6,
|
||
...proseWrap,
|
||
}}
|
||
>
|
||
<span
|
||
style={proseWrap}
|
||
dangerouslySetInnerHTML={{
|
||
__html: renderMarkdown(stripRawToolLogs(text)),
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TimelineToolGroup({
|
||
entries,
|
||
}: {
|
||
category: string;
|
||
entries: Array<Extract<TimelineEntry, { kind: "tool" }>>;
|
||
}) {
|
||
return (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 6,
|
||
margin: "8px 0",
|
||
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
|
||
}}
|
||
>
|
||
{entries.map((e, i) => {
|
||
const isError = e.status === "error";
|
||
const isDone = e.status === "done";
|
||
|
||
let argSummary = "";
|
||
if (
|
||
e.args &&
|
||
!e.name.includes("dev_server_start") &&
|
||
!e.name.includes("dev.server.start") &&
|
||
!e.name.includes("shell_exec") &&
|
||
!e.name.includes("shell.exec")
|
||
) {
|
||
if (e.args.path) argSummary = String(e.args.path);
|
||
else if (e.args.url)
|
||
argSummary = String(e.args.url).replace(/^https?:\/\//, "");
|
||
else if (e.args.name) argSummary = String(e.args.name);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={i}
|
||
style={{
|
||
fontSize: "0.75rem",
|
||
color: isError ? "#ef4444" : "#52525b",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<span style={{ display: "flex", alignItems: "center", width: 14 }}>
|
||
{isError ? (
|
||
"✗"
|
||
) : !isDone ? (
|
||
<Loader2
|
||
style={{ width: 12, height: 12, color: "#8b5cf6" }}
|
||
className="animate-spin"
|
||
/>
|
||
) : (
|
||
<span style={{ color: "#a1a1aa" }}>✓</span>
|
||
)}
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontFamily: "var(--font-mono), monospace",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{friendlyToolName(e.name)}
|
||
</span>
|
||
{argSummary && (
|
||
<span
|
||
style={{
|
||
background: "#f4f4f5",
|
||
padding: "2px 6px",
|
||
borderRadius: 4,
|
||
color: "#71717a",
|
||
maxWidth: 200,
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{argSummary}
|
||
</span>
|
||
)}
|
||
{(() => {
|
||
const summary = summarizeToolResult(e.result);
|
||
if (!summary) return null;
|
||
return (
|
||
<span
|
||
style={{
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
maxWidth: 220,
|
||
color: summary.ok ? "#a1a1aa" : "#ef4444",
|
||
}}
|
||
title={summary.label}
|
||
>
|
||
— {summary.label}
|
||
</span>
|
||
);
|
||
})()}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Main panel ────────────────────────────────────────────────────────────────
|
||
|
||
interface ChatPanelProps {
|
||
/**
|
||
* When true, the panel renders inline as a flex child of its parent
|
||
* (a structural left column on project pages). Skips the fixed-position
|
||
* slide-out treatment, the collapsed-tab affordance, and the
|
||
* --chat-panel-width side-effect. Always "open" — there's no close
|
||
* button because the panel IS the column.
|
||
*
|
||
* When false / omitted: legacy behavior — fixed slide-out on the
|
||
* right, collapsible, sets --chat-panel-width so the workspace
|
||
* content shifts left to make room.
|
||
*/
|
||
structural?: boolean;
|
||
/**
|
||
* When set with `structural` on a project route, renders a unified shell:
|
||
* full-width top bar (chat controls | section icons) and a split row
|
||
* below (chat column | artifact slot). Omit on slide-out chat.
|
||
*/
|
||
artifactSlot?: ReactNode;
|
||
}
|
||
|
||
/** Shared dimensions for preview-select + send icon buttons in the composer. */
|
||
const COMPOSER_ACTION_BTN_BASE: CSSProperties = {
|
||
flexShrink: 0,
|
||
width: 32,
|
||
height: 32,
|
||
boxSizing: "border-box",
|
||
borderRadius: 8,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: 0,
|
||
};
|
||
|
||
/** Preview pick chip + select-mode toggle for unified project shell chat composer. */
|
||
function ProjectPreviewChatInputWrap({
|
||
unifiedShell,
|
||
children,
|
||
}: {
|
||
unifiedShell: boolean;
|
||
children: (selectToggle: React.ReactNode) => React.ReactNode;
|
||
}) {
|
||
const bridge = usePreviewBridge();
|
||
|
||
if (!unifiedShell || !bridge) {
|
||
return <>{children(null)}</>;
|
||
}
|
||
|
||
const { selectMode, setSelectMode, picked, clearPick } = bridge;
|
||
|
||
const chip = picked ? (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 8,
|
||
marginBottom: 8,
|
||
padding: "8px 10px",
|
||
borderRadius: 8,
|
||
background: "#eef2ff",
|
||
border: "1px solid #c7d2fe",
|
||
fontSize: "0.72rem",
|
||
lineHeight: 1.45,
|
||
color: "#312e81",
|
||
}}
|
||
>
|
||
<MousePointerClick
|
||
style={{
|
||
width: 14,
|
||
height: 14,
|
||
flexShrink: 0,
|
||
marginTop: 2,
|
||
opacity: 0.85,
|
||
}}
|
||
aria-hidden
|
||
/>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontWeight: 600, marginBottom: 2 }}>
|
||
Preview selection
|
||
</div>
|
||
<div style={{ opacity: 0.92 }}>
|
||
<span
|
||
style={{
|
||
fontFamily: "var(--font-ibm-plex-mono),monospace",
|
||
fontSize: "0.68rem",
|
||
}}
|
||
>
|
||
{picked.tagName}
|
||
</span>
|
||
{" · "}
|
||
<span style={{ wordBreak: "break-word" }}>{picked.selector}</span>
|
||
</div>
|
||
{picked.textSnippet ? (
|
||
<div style={{ marginTop: 4, opacity: 0.88, wordBreak: "break-word" }}>
|
||
{`"${picked.textSnippet.slice(0, 140)}${picked.textSnippet.length > 140 ? "..." : ""}"`}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={clearPick}
|
||
aria-label="Clear preview selection"
|
||
style={{
|
||
flexShrink: 0,
|
||
background: "none",
|
||
border: "none",
|
||
padding: 2,
|
||
cursor: "pointer",
|
||
borderRadius: 4,
|
||
color: "#4338ca",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<X style={{ width: 13, height: 13 }} />
|
||
</button>
|
||
</div>
|
||
) : null;
|
||
|
||
const selectToggle = (
|
||
<button
|
||
type="button"
|
||
aria-pressed={selectMode}
|
||
aria-label={
|
||
selectMode ? "Exit preview select mode" : "Select element from preview"
|
||
}
|
||
title={
|
||
selectMode
|
||
? "Click preview to choose an element"
|
||
: "Choose element from preview"
|
||
}
|
||
onClick={() => setSelectMode(!selectMode)}
|
||
style={{
|
||
...COMPOSER_ACTION_BTN_BASE,
|
||
border: selectMode ? "1px solid #6366f1" : "1px solid #e8e4dc",
|
||
background: selectMode ? "#eef2ff" : "#faf8f5",
|
||
color: selectMode ? "#4338ca" : "#6b6560",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<MousePointerClick style={{ width: 15, height: 15 }} strokeWidth={2} />
|
||
</button>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{chip}
|
||
{children(selectToggle)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
const suggestionChipStyle: React.CSSProperties = {
|
||
background: "#f4f4f5",
|
||
border: "1px solid #e4e4e7",
|
||
borderRadius: 16,
|
||
padding: "4px 10px",
|
||
fontSize: "0.72rem",
|
||
color: "#52525b",
|
||
cursor: "pointer",
|
||
whiteSpace: "nowrap",
|
||
transition: "all 0.1s ease",
|
||
};
|
||
|
||
export function ChatPanel({
|
||
structural = false,
|
||
artifactSlot,
|
||
}: ChatPanelProps = {}) {
|
||
const { status } = useSession();
|
||
const params = useParams();
|
||
const pathname = usePathname() ?? "";
|
||
const workspace = (params?.workspace as string) || "";
|
||
// When the user is on a /project/<id>/* route, scope the chat to
|
||
// that project. The threads list, the new-thread create call, and
|
||
// the system prompt all branch on this; the chat header surfaces it
|
||
// so the user knows the AI is "talking about" the right thing.
|
||
const projectId = (params?.projectId as string) || "";
|
||
/** Full project shell (chat | artifact); must render even while auth is loading or signed out. */
|
||
const unifiedProjectShell =
|
||
structural && Boolean(projectId) && artifactSlot !== undefined;
|
||
const [activeProjectName, setActiveProjectName] = useState<string | null>(
|
||
null,
|
||
);
|
||
|
||
const [open, setOpen] = useState(() => {
|
||
// Structural mode is always-open by definition — the panel IS the
|
||
// column, there's no "closed" state to persist.
|
||
if (structural) return true;
|
||
if (typeof window === "undefined") return false;
|
||
return localStorage.getItem("vibn-chat-open") !== "false";
|
||
});
|
||
const [threads, setThreads] = useState<Thread[]>([]);
|
||
// threadsLoaded flips to true after the FIRST loadThreads() resolves.
|
||
// Used to gate the auto-create effect — without it we race the fetch
|
||
// and spawn an empty thread before history loads.
|
||
const [threadsLoaded, setThreadsLoaded] = useState(false);
|
||
const [activeThread, setActiveThread] = useState<string | null>(null);
|
||
const [messages, setMessages] = useState<Message[]>([]);
|
||
const [input, setInput] = useState("");
|
||
const [chatMode, setChatMode] = useState<"collaborate" | "vibe" | "delegate">(
|
||
"vibe",
|
||
);
|
||
const [projectFiles, setProjectFiles] = useState<string[]>([]);
|
||
const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
|
||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||
const [suggestionsFilter, setSuggestionsFilter] = useState("");
|
||
const [suggestionIndex, setSuggestionIndex] = useState(0);
|
||
|
||
// Fetch codebase files list inside the scoped project
|
||
useEffect(() => {
|
||
if (!projectId || !workspace || status !== "authenticated") return;
|
||
fetch(`/api/projects/${projectId}/files?workspace=${workspace}`)
|
||
.then((res) => res.json())
|
||
.then((data) => {
|
||
if (Array.isArray(data.files)) {
|
||
setProjectFiles(data.files);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}, [projectId, workspace, status]);
|
||
const [sending, setSending] = useState(false);
|
||
const [currentPhaseLabel, setCurrentPhaseLabel] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [showThreads, setShowThreads] = useState(false);
|
||
const [mcpToken, setMcpToken] = useState<string | null>(null);
|
||
const [isChatMinimized, setIsChatMinimized] = useState<boolean>(false);
|
||
|
||
// Auto-minimize when navigating to dashboard, auto-open when navigating to preview
|
||
useEffect(() => {
|
||
setIsChatMinimized(!pathname.includes("/preview"));
|
||
}, [pathname]);
|
||
|
||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
// AbortController for the in-flight /api/chat fetch. Lives in a ref
|
||
// so the Stop button can reach it without re-rendering on every
|
||
// streaming chunk.
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
|
||
const scrollToBottom = useCallback(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
}, []);
|
||
|
||
// Persist open state + adjust main content margin
|
||
useEffect(() => {
|
||
if (structural) return;
|
||
localStorage.setItem("vibn-chat-open", String(open));
|
||
document.documentElement.style.setProperty(
|
||
"--chat-panel-width",
|
||
open ? "380px" : "0px",
|
||
);
|
||
}, [open, structural]);
|
||
|
||
// Load MCP token — fetch fresh from API on mount to avoid stale, revoked tokens.
|
||
useEffect(() => {
|
||
if (!workspace || status !== "authenticated") return;
|
||
fetch("/api/workspaces?include_default_token=true")
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((d) => {
|
||
if (d?.defaultToken) {
|
||
setMcpToken(d.defaultToken);
|
||
}
|
||
});
|
||
}, [workspace, status]);
|
||
|
||
// Load threads (scoped to the current project when one is in the URL).
|
||
// Reset the loaded flag when projectId changes so the resume effect
|
||
// re-runs against the correct list and doesn't restore a thread from
|
||
// the previous project.
|
||
const loadThreads = useCallback(async () => {
|
||
if (!workspace || status !== "authenticated") return;
|
||
try {
|
||
const qs = new URLSearchParams({ workspace });
|
||
if (projectId) qs.set("projectId", projectId);
|
||
const res = await fetch(`/api/chat/threads?${qs.toString()}`);
|
||
const data = await res.json();
|
||
setThreads(data.threads || []);
|
||
} catch {
|
||
/* silent */
|
||
} finally {
|
||
setThreadsLoaded(true);
|
||
}
|
||
}, [workspace, projectId, status]);
|
||
|
||
useEffect(() => {
|
||
setThreadsLoaded(false);
|
||
setActiveThread(null);
|
||
setMessages([]);
|
||
// Clear the threads array immediately so the resume effect doesn't
|
||
// race the loadThreads() fetch and resume a stale project-scoped
|
||
// thread when the user navigates from /project/X back to /projects.
|
||
setThreads([]);
|
||
loadThreads();
|
||
}, [loadThreads, projectId]);
|
||
|
||
// Look up the active project's display name once we have a projectId,
|
||
// so the chat header can show "Talking about: <name>".
|
||
useEffect(() => {
|
||
if (!projectId) {
|
||
setActiveProjectName(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
fetch(`/api/projects/${projectId}/anatomy`, { credentials: "include" })
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((d) => {
|
||
if (cancelled) return;
|
||
const name = d?.project?.name;
|
||
if (name) setActiveProjectName(name);
|
||
})
|
||
.catch(() => {});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId]);
|
||
|
||
// Create and activate a new thread (tagged to the active project, if any).
|
||
const newThread = useCallback(async () => {
|
||
try {
|
||
const res = await fetch("/api/chat/threads", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ workspace, projectId: projectId || undefined }),
|
||
});
|
||
const data = await res.json();
|
||
if (data.thread) {
|
||
setThreads((prev) => [data.thread, ...prev]);
|
||
setActiveThread(data.thread.id);
|
||
setMessages([]);
|
||
setShowThreads(false);
|
||
}
|
||
} catch {
|
||
/* silent */
|
||
}
|
||
}, [workspace, projectId]);
|
||
|
||
// Load thread messages
|
||
const loadThread = useCallback(async (id: string) => {
|
||
setActiveThread(id);
|
||
setShowThreads(false);
|
||
setMessages([]);
|
||
try {
|
||
const res = await fetch(`/api/chat/threads/${id}`);
|
||
const data = await res.json();
|
||
// Hydrate the timeline from persisted textSegments + toolCalls
|
||
// so a reloaded thread renders the same per-round bubbles the
|
||
// user saw during streaming. Older messages without
|
||
// textSegments fall back to the legacy single-bubble path.
|
||
const hydrated = (data.messages || []).map(
|
||
(m: {
|
||
role: string;
|
||
textSegments?: string[];
|
||
toolCalls?: Array<{
|
||
name: string;
|
||
args: Record<string, unknown>;
|
||
result?: string;
|
||
}>;
|
||
}) => {
|
||
if (m.role !== "assistant") return m as unknown as Message;
|
||
const segs: string[] = Array.isArray(m.textSegments)
|
||
? m.textSegments
|
||
: [];
|
||
if (segs.length === 0) return m as unknown as Message;
|
||
const timeline: TimelineEntry[] = segs.map((t) => ({
|
||
kind: "text",
|
||
text: t,
|
||
}));
|
||
// We don't have round-level interleaving for tool calls in
|
||
// the persisted shape (the schema flattens them), so we drop
|
||
// the toolCalls into the timeline at the end. The streamed
|
||
// shape preserves true ordering; this is just a reload
|
||
// approximation. Good enough — what the user really cares
|
||
// about is the text segments not run-on'ing into one blob.
|
||
if (Array.isArray(m.toolCalls)) {
|
||
for (const tc of m.toolCalls) {
|
||
timeline.push({ kind: "tool", name: tc.name, status: "done" });
|
||
}
|
||
}
|
||
return { ...m, timeline, content: "" } as unknown as Message;
|
||
},
|
||
);
|
||
setMessages(hydrated);
|
||
} catch {
|
||
/* silent */
|
||
}
|
||
}, []);
|
||
|
||
// Auto-resume previous thread (or create a fresh one if the user has
|
||
// never chatted in this workspace). We MUST wait for threadsLoaded
|
||
// before deciding — otherwise we race the fetch and spawn an empty
|
||
// thread before history arrives. Last-active thread is restored from
|
||
// localStorage so a page reload (deploy, refresh) lands the user back
|
||
// in the conversation they were in.
|
||
useEffect(() => {
|
||
if (!open || status !== "authenticated" || !workspace) return;
|
||
if (!threadsLoaded) return;
|
||
if (activeThread) return;
|
||
|
||
if (threads.length === 0) {
|
||
newThread();
|
||
return;
|
||
}
|
||
|
||
const scopeKey = projectId ? `${workspace}:${projectId}` : workspace;
|
||
const savedKey = `vibn-chat-active-thread:${scopeKey}`;
|
||
const saved =
|
||
typeof window !== "undefined" ? localStorage.getItem(savedKey) : null;
|
||
const target =
|
||
saved && threads.some((t) => t.id === saved) ? saved : threads[0].id;
|
||
loadThread(target);
|
||
}, [
|
||
open,
|
||
status,
|
||
workspace,
|
||
projectId,
|
||
threadsLoaded,
|
||
threads,
|
||
activeThread,
|
||
newThread,
|
||
loadThread,
|
||
]);
|
||
|
||
// Persist active thread so reload re-opens the same conversation,
|
||
// keyed per-project so each project has its own "last conversation".
|
||
useEffect(() => {
|
||
if (typeof window === "undefined" || !workspace) return;
|
||
const scopeKey = projectId ? `${workspace}:${projectId}` : workspace;
|
||
const savedKey = `vibn-chat-active-thread:${scopeKey}`;
|
||
if (activeThread) localStorage.setItem(savedKey, activeThread);
|
||
}, [activeThread, workspace, projectId]);
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [messages, scrollToBottom]);
|
||
|
||
const deleteThread = useCallback(
|
||
async (id: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
await fetch(`/api/chat/threads/${id}`, { method: "DELETE" });
|
||
setThreads((prev) => prev.filter((t) => t.id !== id));
|
||
if (activeThread === id) {
|
||
setActiveThread(null);
|
||
setMessages([]);
|
||
}
|
||
loadThreads();
|
||
},
|
||
[activeThread, loadThreads],
|
||
);
|
||
|
||
const sendMessage = useCallback(
|
||
async (override?: string) => {
|
||
let raw = (override ?? input).trim();
|
||
if (!raw || sending || !activeThread) return;
|
||
if (unifiedProjectShell && previewMessagePrepRef.current) {
|
||
raw = previewMessagePrepRef.current(raw);
|
||
}
|
||
const text = raw;
|
||
if (!override) setInput("");
|
||
setSending(true);
|
||
setCurrentPhaseLabel("Starting...");
|
||
|
||
const userMsg: Message = { role: "user", content: text };
|
||
setMessages((prev) => [...prev, userMsg]);
|
||
|
||
let assistantContent = "";
|
||
const assistantMsg: Message = { role: "assistant", content: "" };
|
||
let msgIndex = -1;
|
||
|
||
const controller = new AbortController();
|
||
abortRef.current = controller;
|
||
|
||
try {
|
||
// If Delegate mode is selected, route to the background runner instead of streaming chat!
|
||
if (chatMode === "delegate") {
|
||
const r = await fetch(`/api/projects/${projectId}/agent/sessions`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
appName: "frontend",
|
||
appPath: ".",
|
||
task: text,
|
||
}),
|
||
});
|
||
if (!r.ok) {
|
||
const err = await r.json().catch(() => ({}));
|
||
throw new Error(err.error || `HTTP ${r.status}`);
|
||
}
|
||
|
||
setAttachedFiles([]);
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
role: "assistant",
|
||
content:
|
||
"I have started a background runner for this task. You can safely close this browser or work on something else. I will commit and ship the code when I am finished!",
|
||
},
|
||
]);
|
||
setSending(false);
|
||
return;
|
||
}
|
||
|
||
const res = await fetch("/api/chat", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
thread_id: activeThread,
|
||
message: text,
|
||
workspace,
|
||
mcp_token: mcpToken,
|
||
chatMode,
|
||
attachedFiles,
|
||
}),
|
||
signal: controller.signal,
|
||
});
|
||
|
||
setAttachedFiles([]);
|
||
|
||
if (!res.ok || !res.body) throw new Error("Stream failed");
|
||
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buf = "";
|
||
|
||
setMessages((prev) => {
|
||
msgIndex = prev.length;
|
||
return [...prev, { ...assistantMsg }];
|
||
});
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buf += decoder.decode(value, { stream: true });
|
||
|
||
const lines = buf.split("\n");
|
||
buf = lines.pop() ?? "";
|
||
|
||
for (const line of lines) {
|
||
if (!line.startsWith("data: ")) continue;
|
||
let ev: {
|
||
type: string;
|
||
text?: string;
|
||
name?: string;
|
||
result?: string;
|
||
error?: string;
|
||
phase?: string;
|
||
label?: string;
|
||
goal?: string;
|
||
findings?: string;
|
||
args?: Record<string, unknown>;
|
||
};
|
||
try {
|
||
ev = JSON.parse(line.slice(6));
|
||
} catch {
|
||
continue;
|
||
}
|
||
|
||
if (ev.type === "phase" && ev.phase && ev.label) {
|
||
setCurrentPhaseLabel(ev.label);
|
||
// Legacy phase entries in history are ignored
|
||
} else if (ev.type === "text" && ev.text) {
|
||
// Each text SSE event = one round of the model's text
|
||
// output. Push a new "text" timeline entry so the
|
||
// renderer can show multi-round turns as separate
|
||
// bubbles instead of one run-on paragraph. We still
|
||
// maintain `assistantContent` (joined with blank lines)
|
||
// so the legacy single-bubble fallback path and any
|
||
// post-stream consumers still work.
|
||
assistantContent += (assistantContent ? "\n\n" : "") + ev.text;
|
||
setMessages((prev) => {
|
||
const next = [...prev];
|
||
if (msgIndex >= 0 && next[msgIndex]) {
|
||
const tl = next[msgIndex].timeline ?? [];
|
||
next[msgIndex] = {
|
||
...next[msgIndex],
|
||
// Don't write to msg.content during streaming —
|
||
// the timeline is the source of truth. Setting
|
||
// content on every text event re-renders one
|
||
// giant bubble in the bottom slot AND the
|
||
// segmented timeline above it, duplicating the
|
||
// same prose. Persisted messages pick up
|
||
// content via the final flush below.
|
||
timeline: [...tl, { kind: "text", text: ev.text }],
|
||
};
|
||
}
|
||
return next;
|
||
});
|
||
} else if (ev.type === "thinking" && ev.text) {
|
||
// Each thinking event from the server is one round of the
|
||
// model's reasoning. Push as a separate timeline entry so
|
||
// the renderer can show it as its own collapsed pill —
|
||
// 12 rounds become 12 small pills the user can each
|
||
// expand independently, not one giant blob.
|
||
setMessages((prev) => {
|
||
const next = [...prev];
|
||
if (msgIndex >= 0 && next[msgIndex]) {
|
||
const tl = next[msgIndex].timeline ?? [];
|
||
next[msgIndex] = {
|
||
...next[msgIndex],
|
||
timeline: [...tl, { kind: "thought", text: ev.text }],
|
||
};
|
||
}
|
||
return next;
|
||
});
|
||
} else if (ev.type === "tool_start") {
|
||
// Update bottom status label dynamically based on tool name
|
||
const toolLabel =
|
||
friendlyToolName(ev.name ?? "")
|
||
.replace(/^[^\w\s]+/, "")
|
||
.trim() + "...";
|
||
setCurrentPhaseLabel(toolLabel);
|
||
|
||
setMessages((prev) => {
|
||
const next = [...prev];
|
||
if (msgIndex >= 0 && next[msgIndex]) {
|
||
const tl = next[msgIndex].timeline ?? [];
|
||
next[msgIndex] = {
|
||
...next[msgIndex],
|
||
timeline: [
|
||
...tl,
|
||
{
|
||
kind: "tool",
|
||
name: ev.name,
|
||
args: ev.args,
|
||
status: "running",
|
||
},
|
||
],
|
||
};
|
||
}
|
||
return next;
|
||
});
|
||
} else if (ev.type === "tool_result") {
|
||
setMessages((prev) => {
|
||
const next = [...prev];
|
||
if (msgIndex >= 0 && next[msgIndex]) {
|
||
const tl = next[msgIndex].timeline ?? [];
|
||
// Walk backward to the most recent matching running
|
||
// tool entry and mark it done. Avoids cross-matching
|
||
// earlier same-named entries.
|
||
let updated = false;
|
||
const newTl: TimelineEntry[] = [];
|
||
|
||
let isToolErr = false;
|
||
if (typeof ev.result === "string") {
|
||
try {
|
||
// 1. Try to parse as JSON first (e.g. {"ok": true, "errors": []})
|
||
const parsed = JSON.parse(ev.result);
|
||
if (parsed && typeof parsed === "object") {
|
||
if (parsed.ok === true) {
|
||
isToolErr = false;
|
||
} else if (
|
||
parsed.ok === false ||
|
||
parsed.error ||
|
||
parsed.errors?.length > 0
|
||
) {
|
||
isToolErr = true;
|
||
}
|
||
}
|
||
} catch {
|
||
// 2. If it's a raw string (like a bash crash), scan for fatal keywords
|
||
const lower = ev.result.toLowerCase();
|
||
if (
|
||
lower.includes("error") ||
|
||
lower.includes("failed") ||
|
||
lower.includes("unexpected") ||
|
||
lower.includes("not found")
|
||
) {
|
||
isToolErr = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
for (let i = tl.length - 1; i >= 0; i--) {
|
||
const e = tl[i];
|
||
if (
|
||
!updated &&
|
||
e.kind === "tool" &&
|
||
e.name === ev.name &&
|
||
e.status === "running"
|
||
) {
|
||
newTl.unshift({
|
||
...e,
|
||
status: isToolErr ? "error" : "done",
|
||
result: ev.result,
|
||
});
|
||
updated = true;
|
||
} else {
|
||
newTl.unshift(e);
|
||
}
|
||
}
|
||
next[msgIndex] = { ...next[msgIndex], timeline: newTl };
|
||
}
|
||
return next;
|
||
});
|
||
} else if (ev.type === "error") {
|
||
const errText = ev.error || "Unknown error";
|
||
const isToolErr = /tool|mcp|coolify|gitea/i.test(errText);
|
||
const errBubble = isToolErr
|
||
? `⚠️ **Tool error:** ${errText}`
|
||
: `⚠️ ${errText}`;
|
||
assistantContent += (assistantContent ? "\n\n" : "") + errBubble;
|
||
setMessages((prev) => {
|
||
const next = [...prev];
|
||
if (msgIndex >= 0 && next[msgIndex]) {
|
||
const tl = next[msgIndex].timeline ?? [];
|
||
next[msgIndex] = {
|
||
...next[msgIndex],
|
||
timeline: [...tl, { kind: "text", text: errBubble }],
|
||
};
|
||
}
|
||
return next;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Auto-title thread from first message
|
||
const thisThread = threads.find((t) => t.id === activeThread);
|
||
if (thisThread?.title === "New conversation") {
|
||
const title = text.slice(0, 50);
|
||
await fetch(`/api/chat/threads/${activeThread}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ title }),
|
||
});
|
||
setThreads((prev) =>
|
||
prev.map((t) => (t.id === activeThread ? { ...t, title } : t)),
|
||
);
|
||
}
|
||
|
||
loadThreads();
|
||
} catch (e) {
|
||
const isAbort = e instanceof DOMException && e.name === "AbortError";
|
||
if (isAbort) {
|
||
// Server-side will have appended "(stopped by user)" to the
|
||
// partial response and persisted it. We just need to make
|
||
// sure the local UI reflects whatever streamed in before the
|
||
// user clicked Stop — which it already does, because we've
|
||
// been mutating `messages[msgIndex]` chunk-by-chunk above.
|
||
setMessages((prev) => {
|
||
const next = [...prev];
|
||
if (
|
||
msgIndex >= 0 &&
|
||
next[msgIndex] &&
|
||
!next[msgIndex].content.includes("(stopped by user)")
|
||
) {
|
||
next[msgIndex] = {
|
||
...next[msgIndex],
|
||
content:
|
||
(next[msgIndex].content || "") + "\n\n_(stopped by user)_",
|
||
};
|
||
}
|
||
return next;
|
||
});
|
||
} else {
|
||
const errMsg = e instanceof Error ? e.message : String(e);
|
||
const isNetwork = /fetch|network|failed to fetch/i.test(errMsg);
|
||
const friendlyError = isNetwork
|
||
? "⚠️ Network error — check your connection and try again."
|
||
: `⚠️ Something went wrong: ${errMsg.slice(0, 200)}\n\nYou can try again or start a new message.`;
|
||
setMessages((prev) => {
|
||
const next = [...prev];
|
||
if (msgIndex >= 0 && next[msgIndex]) {
|
||
next[msgIndex] = { ...next[msgIndex], content: friendlyError };
|
||
}
|
||
return next;
|
||
});
|
||
}
|
||
} finally {
|
||
abortRef.current = null;
|
||
setSending(false);
|
||
setCurrentPhaseLabel(null);
|
||
}
|
||
},
|
||
[
|
||
input,
|
||
sending,
|
||
activeThread,
|
||
workspace,
|
||
mcpToken,
|
||
threads,
|
||
loadThreads,
|
||
unifiedProjectShell,
|
||
chatMode,
|
||
projectId,
|
||
attachedFiles,
|
||
],
|
||
);
|
||
|
||
const cancelMessage = useCallback(() => {
|
||
abortRef.current?.abort();
|
||
}, []);
|
||
|
||
// External components (e.g. ProjectHeaderUrls' "Start preview" button)
|
||
// can ask the chat to send a canned prompt without prop-drilling. Open
|
||
// the panel if collapsed, then fire the prompt as if the user typed it.
|
||
useEffect(() => {
|
||
function onPrompt(e: Event) {
|
||
const ce = e as CustomEvent<{ prompt?: string; scopeProjectId?: string }>;
|
||
const prompt = ce.detail?.prompt;
|
||
if (!prompt) return;
|
||
// If the dispatcher scopes the prompt to a specific project, only
|
||
// accept it when the chat panel is currently bound to that project.
|
||
// Prevents a "Start preview on Manifest" click from accidentally
|
||
// landing in a chat that's scoped to a different project.
|
||
if (ce.detail?.scopeProjectId && ce.detail.scopeProjectId !== projectId) {
|
||
return;
|
||
}
|
||
setOpen(true);
|
||
void sendMessage(prompt);
|
||
}
|
||
window.addEventListener("vibn:chat-prompt", onPrompt as EventListener);
|
||
return () =>
|
||
window.removeEventListener("vibn:chat-prompt", onPrompt as EventListener);
|
||
}, [sendMessage, projectId]);
|
||
|
||
const handleInputChange = (val: string) => {
|
||
setInput(val);
|
||
const match = val.match(/\/(\S*)$/);
|
||
if (match) {
|
||
setShowSuggestions(true);
|
||
setSuggestionsFilter(match[1] || "");
|
||
setSuggestionIndex(0);
|
||
} else {
|
||
setShowSuggestions(false);
|
||
}
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (showSuggestions) {
|
||
const filtered = projectFiles
|
||
.filter((f) =>
|
||
f.toLowerCase().includes(suggestionsFilter.toLowerCase()),
|
||
)
|
||
.slice(0, 8);
|
||
|
||
if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
setSuggestionIndex((prev) => (prev + 1) % Math.max(1, filtered.length));
|
||
} else if (e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
setSuggestionIndex(
|
||
(prev) => (prev - 1 + filtered.length) % Math.max(1, filtered.length),
|
||
);
|
||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||
e.preventDefault();
|
||
if (filtered[suggestionIndex]) {
|
||
const selectedFile = filtered[suggestionIndex];
|
||
const before = input.slice(0, input.lastIndexOf("/"));
|
||
setInput(before + " ");
|
||
if (!attachedFiles.includes(selectedFile)) {
|
||
setAttachedFiles((prev) => [...prev, selectedFile]);
|
||
}
|
||
setShowSuggestions(false);
|
||
}
|
||
} else if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
setShowSuggestions(false);
|
||
}
|
||
} else {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendMessage();
|
||
} else if (e.key === "Escape" && sending) {
|
||
e.preventDefault();
|
||
cancelMessage();
|
||
}
|
||
}
|
||
};
|
||
|
||
// Slide-out chat hidden until signed in. Structural project shell always
|
||
// mounts so Preview / Product / Plan pages render; chat column shows loading
|
||
// or sign-in instead of wiping the whole viewport (blank page).
|
||
if (!unifiedProjectShell && status !== "authenticated") return null;
|
||
|
||
// ── Collapsed tab ──────────────────────────────────────────────────────────
|
||
// Structural mode is always-open; skip the collapsed-tab branch entirely.
|
||
if (!open && !structural) {
|
||
return (
|
||
<button
|
||
onClick={() => setOpen(true)}
|
||
style={{
|
||
position: "fixed",
|
||
right: 0,
|
||
top: "50%",
|
||
transform: "translateY(-50%)",
|
||
zIndex: 1000,
|
||
background: "#1a1a1a",
|
||
color: "#fff",
|
||
border: "none",
|
||
borderRadius: "8px 0 0 8px",
|
||
padding: "14px 10px",
|
||
cursor: "pointer",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
boxShadow: "-2px 0 12px #1a1a1a14",
|
||
}}
|
||
title="Open Vibn AI"
|
||
>
|
||
<MessageSquare style={{ width: 16, height: 16 }} />
|
||
<span
|
||
style={{
|
||
writingMode: "vertical-rl",
|
||
textOrientation: "mixed",
|
||
fontSize: "0.65rem",
|
||
fontWeight: 600,
|
||
letterSpacing: "0.08em",
|
||
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
|
||
transform: "rotate(180deg)",
|
||
}}
|
||
>
|
||
VIBN AI
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const conversationColumn = (
|
||
<>
|
||
{/* Thread list dropdown */}
|
||
{showThreads && (
|
||
<div
|
||
style={{
|
||
borderBottom: "1px solid #e8e4dc",
|
||
background: "#faf8f5",
|
||
maxHeight: 200,
|
||
overflowY: "auto",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
{threads.length === 0 && (
|
||
<div
|
||
style={{
|
||
padding: "12px 16px",
|
||
fontSize: "0.78rem",
|
||
color: "#a09a90",
|
||
}}
|
||
>
|
||
No conversations yet
|
||
</div>
|
||
)}
|
||
{threads.map((t) => (
|
||
<div
|
||
key={t.id}
|
||
onClick={() => loadThread(t.id)}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
padding: "9px 16px",
|
||
background: activeThread === t.id ? "#f0ede8" : "transparent",
|
||
cursor: "pointer",
|
||
borderBottom: "1px solid #f0ede8",
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (activeThread !== t.id)
|
||
e.currentTarget.style.background = "#f7f4ef";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (activeThread !== t.id)
|
||
e.currentTarget.style.background = "transparent";
|
||
}}
|
||
>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div
|
||
style={{
|
||
fontSize: "0.8rem",
|
||
fontWeight: 500,
|
||
color: "#1a1a1a",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{t.title}
|
||
</div>
|
||
<div style={{ fontSize: "0.7rem", color: "#a09a90" }}>
|
||
{timeAgo(t.updatedAt)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
onClick={(e) => deleteThread(t.id, e)}
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
padding: "2px 4px",
|
||
color: "#c0bab2",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<Trash2 style={{ width: 12, height: 12 }} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Messages */}
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minWidth: 0,
|
||
overflowY: "auto",
|
||
overflowX: "hidden",
|
||
padding: "16px 14px",
|
||
}}
|
||
>
|
||
{messages.length === 0 && !sending && (
|
||
<div
|
||
style={{ padding: "24px 14px", maxWidth: 640, margin: "0 auto" }}
|
||
>
|
||
<div style={{ display: "flex", gap: 12 }}>
|
||
<div
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: 6,
|
||
background: "#1a1a1a",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
flexShrink: 0,
|
||
color: "#fff",
|
||
fontSize: "0.85rem",
|
||
fontFamily: "var(--font-lora),serif",
|
||
}}
|
||
>
|
||
V
|
||
</div>
|
||
<div
|
||
style={{
|
||
background: "#fff",
|
||
border: "1px solid #e8e4dc",
|
||
padding: "14px 18px",
|
||
borderRadius: "0 12px 12px 12px",
|
||
fontSize: "0.9rem",
|
||
color: "#1a1a1a",
|
||
lineHeight: 1.6,
|
||
boxShadow: "0 1px 2px #1a1a1a05",
|
||
maxWidth: "85%",
|
||
}}
|
||
>
|
||
Welcome to {activeProjectName ? activeProjectName : "Vibn"}!
|
||
Tell me what you want to build and I'll scaffold it, run it
|
||
in a preview, and ship it when you say so.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{messages.map((msg, i) => (
|
||
<MessageBubble key={msg.id || i} msg={msg} />
|
||
))}
|
||
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
{/* Input */}
|
||
<div
|
||
style={{
|
||
padding: "12px 14px",
|
||
borderTop: "1px solid #e8e4dc",
|
||
background: "#faf8f5",
|
||
flexShrink: 0,
|
||
position: "relative",
|
||
}}
|
||
>
|
||
{!mcpToken && (
|
||
<div
|
||
style={{
|
||
fontSize: "0.7rem",
|
||
color: "#9a7b3a",
|
||
background: "#d4a04a12",
|
||
border: "1px solid #d4a04a30",
|
||
borderRadius: 6,
|
||
padding: "5px 10px",
|
||
marginBottom: 8,
|
||
lineHeight: 1.4,
|
||
}}
|
||
>
|
||
Read-only mode — add your MCP token in Settings to enable actions.
|
||
</div>
|
||
)}
|
||
{/* File Autocomplete Suggestions */}
|
||
{showSuggestions &&
|
||
(() => {
|
||
const filtered = projectFiles
|
||
.filter((f) =>
|
||
f.toLowerCase().includes(suggestionsFilter.toLowerCase()),
|
||
)
|
||
.slice(0, 8);
|
||
if (filtered.length === 0) return null;
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
bottom: "calc(100% - 10px)",
|
||
left: 14,
|
||
right: 14,
|
||
background: "#fff",
|
||
border: "1px solid #e8e4dc",
|
||
borderRadius: 8,
|
||
boxShadow:
|
||
"0 -4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.02)",
|
||
zIndex: 1000,
|
||
maxHeight: 200,
|
||
overflowY: "auto",
|
||
padding: "4px 0",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: "0.65rem",
|
||
fontWeight: 600,
|
||
color: "#a09a90",
|
||
padding: "6px 12px 4px",
|
||
borderBottom: "1px solid #f0ede8",
|
||
marginBottom: 4,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.05em",
|
||
}}
|
||
>
|
||
Codebase Files
|
||
</div>
|
||
{filtered.map((file, idx) => (
|
||
<div
|
||
key={file}
|
||
onClick={() => {
|
||
const before = input.slice(0, input.lastIndexOf("/"));
|
||
setInput(before + " ");
|
||
if (!attachedFiles.includes(file)) {
|
||
setAttachedFiles((prev) => [...prev, file]);
|
||
}
|
||
setShowSuggestions(false);
|
||
}}
|
||
onMouseEnter={() => setSuggestionIndex(idx)}
|
||
style={{
|
||
padding: "6px 12px",
|
||
fontSize: "0.76rem",
|
||
fontFamily: "var(--font-mono), monospace",
|
||
color: idx === suggestionIndex ? "#3d5afe" : "#1a1a1a",
|
||
background:
|
||
idx === suggestionIndex ? "#3d5afe0c" : "transparent",
|
||
cursor: "pointer",
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
📄 {file}
|
||
</span>
|
||
{idx === suggestionIndex && (
|
||
<span
|
||
style={{
|
||
fontSize: "0.6rem",
|
||
color: "#3d5afe",
|
||
opacity: 0.7,
|
||
fontFamily: "var(--font-inter), sans-serif",
|
||
}}
|
||
>
|
||
press Enter
|
||
</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Attached Files Chips */}
|
||
{attachedFiles.length > 0 && (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
gap: 6,
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
{attachedFiles.map((file) => (
|
||
<div
|
||
key={file}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
background: "#eef2ff",
|
||
border: "1px solid #c7d2fe",
|
||
borderRadius: 6,
|
||
padding: "4px 8px",
|
||
fontSize: "0.7rem",
|
||
color: "#312e81",
|
||
fontFamily: "var(--font-mono), monospace",
|
||
}}
|
||
>
|
||
<span>📄 {file.split("/").pop()}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setAttachedFiles((prev) => prev.filter((f) => f !== file))
|
||
}
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
padding: 0,
|
||
cursor: "pointer",
|
||
color: "#4338ca",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
}}
|
||
title="Remove file"
|
||
>
|
||
<X style={{ width: 12, height: 12 }} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Action Status Bar anchored above composer */}
|
||
{sending && currentPhaseLabel && (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
padding: "4px 8px 10px",
|
||
marginLeft: 4,
|
||
}}
|
||
>
|
||
<Loader2
|
||
className="animate-spin"
|
||
style={{ width: 14, height: 14, color: "#8b5cf6" }}
|
||
/>
|
||
<span
|
||
style={{
|
||
fontSize: "0.75rem",
|
||
fontWeight: 500,
|
||
color: "#71717a",
|
||
}}
|
||
>
|
||
{currentPhaseLabel}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<ProjectPreviewChatInputWrap unifiedShell={unifiedProjectShell}>
|
||
{(selectToggle) => (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 8,
|
||
background: "#fff",
|
||
borderRadius: 12,
|
||
border: "1px solid #e4e4e7",
|
||
padding: "10px 12px",
|
||
boxShadow: "0 2px 6px rgba(0,0,0,0.03)",
|
||
}}
|
||
>
|
||
<textarea
|
||
ref={inputRef}
|
||
value={input}
|
||
onChange={(e) => handleInputChange(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={
|
||
sending ? "Esc to stop generating…" : "Ask Vibn AI anything…"
|
||
}
|
||
rows={3}
|
||
disabled={!activeThread}
|
||
style={{
|
||
width: "100%",
|
||
border: "none",
|
||
outline: "none",
|
||
background: "transparent",
|
||
fontSize: "0.86rem",
|
||
lineHeight: 1.55,
|
||
resize: "none",
|
||
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
|
||
color: "#1a1a1a",
|
||
maxHeight: 240,
|
||
overflowY: "auto",
|
||
}}
|
||
onInput={(e) => {
|
||
const el = e.currentTarget;
|
||
// Only resize if height actually changed
|
||
el.style.height = "auto";
|
||
el.style.height = Math.min(el.scrollHeight, 240) + "px";
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "flex-end",
|
||
gap: 8,
|
||
paddingTop: 4,
|
||
}}
|
||
>
|
||
<div style={{ display: "flex", gap: 6, flex: 1 }}>
|
||
{/* Mode Toggles */}
|
||
<select
|
||
value={chatMode}
|
||
onChange={(e) => setChatMode(e.target.value as any)}
|
||
style={{
|
||
appearance: "none",
|
||
background: "transparent",
|
||
border: "none",
|
||
fontSize: "0.75rem",
|
||
fontWeight: 500,
|
||
color: "#71717a",
|
||
cursor: "pointer",
|
||
outline: "none",
|
||
padding: "4px 8px",
|
||
}}
|
||
>
|
||
<option value="vibe">Discuss</option>
|
||
<option value="collaborate">Plan</option>
|
||
</select>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 6 }}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const fname = window.prompt(
|
||
"Enter exact file path to attach (e.g. src/app/page.tsx):",
|
||
);
|
||
if (fname && fname.trim()) {
|
||
setAttachedFiles((prev) => [...prev, fname.trim()]);
|
||
}
|
||
}}
|
||
style={{
|
||
...COMPOSER_ACTION_BTN_BASE,
|
||
background: "transparent",
|
||
color: "#a1a1aa",
|
||
border: "1px solid transparent",
|
||
cursor: "pointer",
|
||
transition: "all 0.1s ease",
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = "#f4f4f5";
|
||
e.currentTarget.style.color = "#18181b";
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = "transparent";
|
||
e.currentTarget.style.color = "#a1a1aa";
|
||
}}
|
||
title="Attach file to context"
|
||
>
|
||
<Paperclip style={{ width: 14, height: 14 }} />
|
||
</button>
|
||
</div>
|
||
{(() => {
|
||
// While the AI is streaming or running tools, the button
|
||
// turns into a Stop control. Click → AbortController fires,
|
||
// server bails between rounds, partial text gets persisted.
|
||
const isActive = sending;
|
||
const canSend = !sending && input.trim() && activeThread;
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={isActive ? cancelMessage : () => sendMessage()}
|
||
disabled={!isActive && !canSend}
|
||
aria-label={isActive ? "Stop generating" : "Send message"}
|
||
title={isActive ? "Stop generating (Esc)" : "Send"}
|
||
style={{
|
||
...COMPOSER_ACTION_BTN_BASE,
|
||
background: isActive
|
||
? "#111827"
|
||
: canSend
|
||
? "#111827"
|
||
: "#e4e4e7",
|
||
color: isActive || canSend ? "#fff" : "#a1a1aa",
|
||
border: "none",
|
||
cursor: isActive || canSend ? "pointer" : "not-allowed",
|
||
transition: "all 0.15s ease",
|
||
}}
|
||
>
|
||
{isActive ? (
|
||
<>
|
||
<Square
|
||
style={{
|
||
width: 11,
|
||
height: 11,
|
||
fill: "currentColor",
|
||
strokeWidth: 3,
|
||
}}
|
||
/>
|
||
</>
|
||
) : (
|
||
<Send style={{ width: 14, height: 14 }} />
|
||
)}
|
||
</button>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</ProjectPreviewChatInputWrap>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
const structuralChatLoadingColumn = (
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 0,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: 24,
|
||
gap: 14,
|
||
}}
|
||
>
|
||
<Loader2
|
||
className="animate-spin"
|
||
style={{ width: 22, height: 22, color: "#9c9590" }}
|
||
/>
|
||
<p style={{ fontSize: "0.84rem", color: "#6b6560", margin: 0 }}>
|
||
Signing you in…
|
||
</p>
|
||
</div>
|
||
);
|
||
|
||
const authHref = pathname
|
||
? `/signin?callbackUrl=${encodeURIComponent(pathname)}`
|
||
: "/signin";
|
||
|
||
const structuralChatSignedOutColumn = (
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 0,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: 24,
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<p
|
||
style={{
|
||
fontSize: "0.88rem",
|
||
fontWeight: 500,
|
||
color: "#1a1a1a",
|
||
margin: 0,
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
Sign in to use Vibn AI
|
||
</p>
|
||
<p
|
||
style={{
|
||
fontSize: "0.76rem",
|
||
color: "#a09a90",
|
||
margin: 0,
|
||
textAlign: "center",
|
||
maxWidth: 260,
|
||
lineHeight: 1.5,
|
||
}}
|
||
>
|
||
Preview and tabs still work here. Chat needs an account.
|
||
</p>
|
||
<Link
|
||
href={authHref}
|
||
style={{
|
||
marginTop: 4,
|
||
padding: "10px 18px",
|
||
borderRadius: 10,
|
||
background: "#1a1a1a",
|
||
color: "#fff",
|
||
fontSize: "0.82rem",
|
||
fontWeight: 500,
|
||
textDecoration: "none",
|
||
}}
|
||
>
|
||
Sign in
|
||
</Link>
|
||
</div>
|
||
);
|
||
|
||
const structuralChatBody =
|
||
status === "loading"
|
||
? structuralChatLoadingColumn
|
||
: status !== "authenticated"
|
||
? structuralChatSignedOutColumn
|
||
: conversationColumn;
|
||
|
||
if (unifiedProjectShell) {
|
||
return (
|
||
<>
|
||
<PreviewBridgeProvider>
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minHeight: 0,
|
||
minWidth: 0,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
|
||
background: "#fff",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "row",
|
||
alignItems: "stretch",
|
||
flexShrink: 0,
|
||
height: 48,
|
||
borderBottom: "1px solid #e4e4e7",
|
||
background: "#faf8f5",
|
||
boxSizing: "border-box",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 380,
|
||
flexShrink: 0,
|
||
height: "100%",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
padding: "0 12px",
|
||
gap: 6,
|
||
boxSizing: "border-box",
|
||
borderRight: "1px solid #e4e4e7",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
width: "100%",
|
||
gap: 6,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
minWidth: 0,
|
||
}}
|
||
>
|
||
<Link
|
||
href={workspace ? `/${workspace}/projects` : "/"}
|
||
title="All projects"
|
||
style={{ flexShrink: 0, display: "flex" }}
|
||
>
|
||
<img
|
||
src="/vibn-black-circle-logo.png"
|
||
alt="VIBN"
|
||
width={26}
|
||
height={26}
|
||
style={{
|
||
borderRadius: 7,
|
||
objectFit: "cover",
|
||
display: "block",
|
||
}}
|
||
/>
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowThreads((v) => !v)}
|
||
aria-label="Conversations"
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
padding: "2px 4px",
|
||
borderRadius: 6,
|
||
minWidth: 0,
|
||
}}
|
||
>
|
||
<ChevronDown
|
||
style={{
|
||
width: 12,
|
||
height: 12,
|
||
color: "#a09a90",
|
||
transition: "transform 0.15s",
|
||
flexShrink: 0,
|
||
transform: showThreads ? "rotate(180deg)" : "none",
|
||
}}
|
||
/>
|
||
{projectId && (
|
||
<span
|
||
title={`Chat is scoped to ${activeProjectName ?? "this project"}. Tool calls assume projectId=${projectId}.`}
|
||
style={{
|
||
marginLeft: 4,
|
||
padding: "1px 6px",
|
||
fontSize: "0.6rem",
|
||
fontWeight: 600,
|
||
letterSpacing: "0.02em",
|
||
color: "#3d5afe",
|
||
background: "#3d5afe14",
|
||
borderRadius: 999,
|
||
maxWidth: 140,
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
}}
|
||
>
|
||
▸ {activeProjectName ?? "this project"}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={newThread}
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
padding: "4px 5px",
|
||
borderRadius: 6,
|
||
color: "#6b6560",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
}}
|
||
title="New conversation"
|
||
>
|
||
<Plus style={{ width: 14, height: 14 }} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
minWidth: 0,
|
||
minHeight: 0,
|
||
display: "flex",
|
||
alignItems: "stretch",
|
||
}}
|
||
>
|
||
<ProjectIconRail
|
||
workspace={workspace}
|
||
projectId={projectId}
|
||
actions={
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (activeThread && !sending) {
|
||
sendMessage("ship it");
|
||
}
|
||
}}
|
||
disabled={!activeThread || sending}
|
||
style={{
|
||
background: "#18181b",
|
||
border: "none",
|
||
cursor:
|
||
activeThread && !sending ? "pointer" : "not-allowed",
|
||
padding: "6px 14px",
|
||
borderRadius: 6,
|
||
color: "#fff",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
fontSize: "0.75rem",
|
||
fontWeight: 600,
|
||
opacity: activeThread && !sending ? 1 : 0.5,
|
||
transition: "opacity 0.15s ease",
|
||
}}
|
||
title="Ship to production"
|
||
>
|
||
Publish
|
||
</button>
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
display: "flex",
|
||
flexDirection: "row",
|
||
minHeight: 0,
|
||
minWidth: 0,
|
||
position: "relative", // Ensure relative positioning for z-index stacking context
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: isChatMinimized ? 52 : 380,
|
||
flexShrink: 0,
|
||
borderRight: "1px solid #e4e4e7",
|
||
background: "#fff",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
minHeight: 0,
|
||
minWidth: 0,
|
||
transition: "width 0.2s ease-in-out",
|
||
overflow: "visible", // Changed from hidden to visible so the collapse button can hang over the edge
|
||
position: "relative",
|
||
zIndex: 40, // High z-index to pop over the right panel
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
zIndex: 50,
|
||
right: -12,
|
||
top: 12,
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => setIsChatMinimized(!isChatMinimized)}
|
||
style={{
|
||
background: "#fff",
|
||
border: "1px solid #e4e4e7",
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: "50%",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
cursor: "pointer",
|
||
color: "#a1a1aa",
|
||
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||
}}
|
||
title={isChatMinimized ? "Expand Chat" : "Minimize Chat"}
|
||
>
|
||
{isChatMinimized ? (
|
||
<ChevronRight size={14} />
|
||
) : (
|
||
<ChevronLeft size={14} />
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{isChatMinimized ? (
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "center",
|
||
paddingTop: 16,
|
||
}}
|
||
></div>
|
||
) : (
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{structuralChatBody}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div
|
||
key={pathname}
|
||
style={{
|
||
flex: 1,
|
||
minWidth: 0,
|
||
minHeight: 0,
|
||
overflow: "auto",
|
||
background: "#faf8f5",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
position: "relative",
|
||
zIndex: 10, // Lower z-index so the collapse button stays on top
|
||
}}
|
||
>
|
||
{artifactSlot}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</PreviewBridgeProvider>
|
||
|
||
<style>{`
|
||
@keyframes vibn-bounce {
|
||
0%, 60%, 100% { transform: translateY(0); }
|
||
30% { transform: translateY(-4px); }
|
||
}
|
||
`}</style>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── Open panel ─────────────────────────────────────────────────────────────
|
||
// Structural mode: fill the parent column. Default mode: fixed slide-out
|
||
// anchored to the right edge of the viewport (legacy behavior).
|
||
return (
|
||
<div
|
||
style={
|
||
structural
|
||
? {
|
||
flex: 1,
|
||
minHeight: 0,
|
||
background: "#fff",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
|
||
}
|
||
: {
|
||
position: "fixed",
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
zIndex: 999,
|
||
width: 380,
|
||
background: "#fff",
|
||
borderLeft: "1px solid #e8e4dc",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
boxShadow: "-4px 0 24px #1a1a1a08",
|
||
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
|
||
}
|
||
}
|
||
>
|
||
{/* Header — structural: circle logo + threads; slide-out: wordmark */}
|
||
<div
|
||
style={
|
||
structural
|
||
? {
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
height: 48,
|
||
padding: "0 12px",
|
||
boxSizing: "border-box",
|
||
borderBottom: "1px solid #e8e4dc",
|
||
background: "#faf8f5",
|
||
flexShrink: 0,
|
||
gap: 6,
|
||
}
|
||
: {
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
padding: "14px 16px",
|
||
borderBottom: "1px solid #e8e4dc",
|
||
background: "#faf8f5",
|
||
flexShrink: 0,
|
||
}
|
||
}
|
||
>
|
||
{structural ? (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
minWidth: 0,
|
||
}}
|
||
>
|
||
<Link
|
||
href={workspace ? `/${workspace}/projects` : "/"}
|
||
title="All projects"
|
||
style={{ flexShrink: 0, display: "flex" }}
|
||
>
|
||
<img
|
||
src="/vibn-black-circle-logo.png"
|
||
alt="VIBN"
|
||
width={26}
|
||
height={26}
|
||
style={{
|
||
borderRadius: 7,
|
||
objectFit: "cover",
|
||
display: "block",
|
||
}}
|
||
/>
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowThreads((v) => !v)}
|
||
aria-label="Conversations"
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
padding: "2px 4px",
|
||
borderRadius: 6,
|
||
minWidth: 0,
|
||
}}
|
||
>
|
||
<ChevronDown
|
||
style={{
|
||
width: 12,
|
||
height: 12,
|
||
color: "#a09a90",
|
||
transition: "transform 0.15s",
|
||
flexShrink: 0,
|
||
transform: showThreads ? "rotate(180deg)" : "none",
|
||
}}
|
||
/>
|
||
{projectId && (
|
||
<span
|
||
title={`Chat is scoped to ${activeProjectName ?? "this project"}. Tool calls assume projectId=${projectId}.`}
|
||
style={{
|
||
marginLeft: 4,
|
||
padding: "1px 6px",
|
||
fontSize: "0.6rem",
|
||
fontWeight: 600,
|
||
letterSpacing: "0.02em",
|
||
color: "#3d5afe",
|
||
background: "#3d5afe14",
|
||
borderRadius: 999,
|
||
maxWidth: 140,
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
}}
|
||
>
|
||
▸ {activeProjectName ?? "this project"}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowThreads((v) => !v)}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
padding: "4px 6px",
|
||
borderRadius: 6,
|
||
minWidth: 0,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontFamily: "var(--font-lora),serif",
|
||
fontSize: "0.95rem",
|
||
fontWeight: 400,
|
||
color: "#1a1a1a",
|
||
letterSpacing: "-0.01em",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
Vibn AI
|
||
</span>
|
||
<ChevronDown
|
||
style={{
|
||
width: 13,
|
||
height: 13,
|
||
color: "#a09a90",
|
||
transition: "transform 0.15s",
|
||
flexShrink: 0,
|
||
transform: showThreads ? "rotate(180deg)" : "none",
|
||
}}
|
||
/>
|
||
{projectId && (
|
||
<span
|
||
title={`Chat is scoped to ${activeProjectName ?? "this project"}. Tool calls assume projectId=${projectId}.`}
|
||
style={{
|
||
marginLeft: 6,
|
||
padding: "2px 8px",
|
||
fontSize: "0.65rem",
|
||
fontWeight: 600,
|
||
letterSpacing: "0.02em",
|
||
color: "#3d5afe",
|
||
background: "#3d5afe14",
|
||
borderRadius: 999,
|
||
maxWidth: 160,
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
}}
|
||
>
|
||
▸ {activeProjectName ?? "this project"}
|
||
</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
<div style={{ display: "flex", gap: 4 }}>
|
||
<button
|
||
type="button"
|
||
onClick={newThread}
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
padding: structural ? "4px 5px" : "5px 6px",
|
||
borderRadius: 6,
|
||
color: "#6b6560",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
}}
|
||
title="New conversation"
|
||
>
|
||
<Plus
|
||
style={{
|
||
width: structural ? 14 : 15,
|
||
height: structural ? 14 : 15,
|
||
}}
|
||
/>
|
||
</button>
|
||
{!structural && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen(false)}
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
padding: "5px 6px",
|
||
borderRadius: 6,
|
||
color: "#6b6560",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
}}
|
||
title="Close"
|
||
>
|
||
<ChevronRight style={{ width: 15, height: 15 }} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{conversationColumn}
|
||
|
||
<style>{`
|
||
@keyframes vibn-bounce {
|
||
0%, 60%, 100% { transform: translateY(0); }
|
||
30% { transform: translateY(-4px); }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|