design(chat): add paperclip button to chat input and auto-resize textarea

This commit is contained in:
2026-06-10 14:13:31 -07:00
parent 9b6dfcab73
commit 6f29152d6e

View File

@@ -26,6 +26,7 @@ import {
Sparkles,
Compass,
Cpu,
Paperclip,
} from "lucide-react";
import { ProjectIconRail } from "@/components/project/project-icon-rail";
import {
@@ -1965,118 +1966,6 @@ export function ChatPanel({
position: "relative",
}}
>
{/* Chat Mode Toggle */}
<div
style={{
display: "flex",
gap: 4,
marginBottom: 8,
padding: "2px",
background: "#f0ede8",
borderRadius: 8,
border: "1px solid #e8e4dc",
}}
>
<button
type="button"
onClick={() => setChatMode("vibe")}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: "6px 0",
fontSize: "0.72rem",
fontWeight: chatMode === "vibe" ? 600 : 500,
borderRadius: 6,
border: "none",
background: chatMode === "vibe" ? "#fff" : "transparent",
color: chatMode === "vibe" ? "#3d5afe" : "#6b6560",
cursor: "pointer",
boxShadow:
chatMode === "vibe" ? "0 1px 2px rgba(0,0,0,0.05)" : "none",
transition: "all 0.1s ease",
}}
title="Vibe Code: Fast, iterative coding with immediate live previews."
>
<Sparkles
style={{
width: 12,
height: 12,
color: chatMode === "vibe" ? "#3d5afe" : "#8c8580",
}}
/>
<span>Vibe Code</span>
</button>
<button
type="button"
onClick={() => setChatMode("collaborate")}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: "6px 0",
fontSize: "0.72rem",
fontWeight: chatMode === "collaborate" ? 600 : 500,
borderRadius: 6,
border: "none",
background: chatMode === "collaborate" ? "#fff" : "transparent",
color: chatMode === "collaborate" ? "#d4a04a" : "#6b6560",
cursor: "pointer",
boxShadow:
chatMode === "collaborate"
? "0 1px 2px rgba(0,0,0,0.05)"
: "none",
transition: "all 0.1s ease",
}}
title="Architect: Brainstorm, spec out features, and plan architecture without writing code."
>
<Compass
style={{
width: 12,
height: 12,
color: chatMode === "collaborate" ? "#d4a04a" : "#8c8580",
}}
/>
<span>Architect</span>
</button>
<button
type="button"
onClick={() => setChatMode("delegate")}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: "6px 0",
fontSize: "0.72rem",
fontWeight: chatMode === "delegate" ? 600 : 500,
borderRadius: 6,
border: "none",
background: chatMode === "delegate" ? "#fff" : "transparent",
color: chatMode === "delegate" ? "#2e7d32" : "#6b6560",
cursor: "pointer",
boxShadow:
chatMode === "delegate" ? "0 1px 2px rgba(0,0,0,0.05)" : "none",
transition: "all 0.1s ease",
}}
title="Delegate Offline: Send this task to the background runner to build automatically while you step away."
>
<Cpu
style={{
width: 12,
height: 12,
color: chatMode === "delegate" ? "#2e7d32" : "#8c8580",
}}
/>
<span>Delegate</span>
</button>
</div>
{!mcpToken && (
<div
style={{
@@ -2241,13 +2130,13 @@ export function ChatPanel({
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
alignItems: "center",
background: "#fff",
borderRadius: 10,
borderRadius: 12,
border: "1px solid #e8e4dc",
padding: "8px 10px",
boxShadow: "0 1px 3px #1a1a1a05",
padding: "10px 12px",
boxShadow: "0 2px 6px #1a1a1a08",
}}
>
<textarea
@@ -2258,86 +2147,116 @@ export function ChatPanel({
placeholder={
sending ? "Esc to stop generating…" : "Ask Vibn AI anything…"
}
rows={1}
rows={3}
disabled={!activeThread}
style={{
flex: 1,
width: "100%",
border: "none",
outline: "none",
background: "transparent",
fontSize: "0.84rem",
lineHeight: 1.5,
fontSize: "0.86rem",
lineHeight: 1.55,
resize: "none",
fontFamily: "var(--font-inter),ui-sans-serif,sans-serif",
color: "#1a1a1a",
maxHeight: 120,
maxHeight: 240,
overflowY: "auto",
}}
onInput={(e) => {
const el = e.currentTarget;
const newlines = (el.value.match(/\n/g) || []).length;
// Cache lastNewlines on the DOM element to avoid state re-render lag
if ((el as any).lastNewlines !== newlines) {
(el as any).lastNewlines = newlines;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
el.style.height = Math.min(el.scrollHeight, 240) + "px";
}
}}
/>
{selectToggle}
{(() => {
// 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 (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingTop: 4,
}}
>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
onClick={isActive ? cancelMessage : () => sendMessage()}
disabled={!isActive && !canSend}
aria-label={isActive ? "Stop generating" : "Send message"}
title={isActive ? "Stop generating (Esc)" : "Send"}
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: isActive
? "#1a1a1a"
: canSend
? "#1a1a1a"
: "#e8e4dc",
color: isActive || canSend ? "#fff" : "#a09a90",
border: "none",
cursor: isActive || canSend ? "pointer" : "default",
transition: "all 0.15s",
position: "relative",
background: "transparent",
color: "#8c8580",
border: "1px solid transparent",
cursor: "pointer",
transition: "all 0.1s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#f0ede8";
e.currentTarget.style.color = "#1a1a1a";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.color = "#8c8580";
}}
title="Attach file to context"
>
{isActive ? (
<>
<Loader2
style={{
width: 15,
height: 15,
position: "absolute",
opacity: 0.35,
}}
className="animate-spin"
/>
<Square
style={{
width: 9,
height: 9,
fill: "#fff",
strokeWidth: 0,
}}
/>
</>
) : (
<Send style={{ width: 15, height: 15 }} />
)}
<Paperclip style={{ width: 14, height: 14 }} />
</button>
);
})()}
{selectToggle}
</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
? "#1a1a1a"
: canSend
? "#1a1a1a"
: "#e8e4dc",
color: isActive || canSend ? "#fff" : "#a09a90",
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>