diff --git a/apps/delta-command/README.md b/apps/delta-command/README.md index d956a13..8ebca0d 100644 --- a/apps/delta-command/README.md +++ b/apps/delta-command/README.md @@ -46,13 +46,13 @@ The whole app is small on purpose: | `DELTA_API_URL` | `http://127.0.0.1:8000` | Backend URL (used by Next.js server-side and rewrites). | | `DELTA_DATA_PATH` | `backend/config/data.json` | JSON database file. | | `DELTA_HOST` / `DELTA_PORT` | `127.0.0.1` / `8000` | Backend bind address. | -| `LLM_API_KEY` | *(unset)* | Enables the **AI Executive Briefing** on the dashboard. | +| `LLM_API_KEY` | *(unset)* | Enables the **Delta chat** surface and executive briefing. | | `LLM_BASE_URL` | `https://api.openai.com/v1` | Any OpenAI-compatible endpoint (Groq, Ollama, etc.). | | `LLM_MODEL` | `gpt-4o-mini` | Model name. | -### AI briefing +### Delta chat -With `LLM_API_KEY` set, a **Generate** button on the dashboard produces a 2–3 paragraph leadership summary of the live pipeline, capacity, and delivery risks. Works with any OpenAI-compatible provider: +With `LLM_API_KEY` set, `/chat` becomes a full conversational surface: composer, message thread, starter prompts, streaming reveal. Every turn sends the current pipeline, team-capacity, and delivery state as the system prompt, so answers stay grounded. The dashboard "Ask Delta" callout deep-links into `/chat?prompt=` and auto-submits the first turn. Works with any OpenAI-compatible provider: ```bash # OpenAI diff --git a/apps/delta-command/backend/README.md b/apps/delta-command/backend/README.md index e178084..e128460 100644 --- a/apps/delta-command/backend/README.md +++ b/apps/delta-command/backend/README.md @@ -18,11 +18,12 @@ uv run delta-command # → http://127.0.0.1:8000 | PATCH | `/api/opportunities` | Update stage (probability auto-set to 100/0 for won/lost). | | PATCH | `/api/projects` | Update status. | | PATCH | `/api/timeline` | Update one weekly utilization cell for one engineer; recomputes engineer.utilization and status. | -| POST | `/api/briefing` | Author an executive briefing via an OpenAI-compatible LLM. Returns `503` if `LLM_API_KEY` is not set. | +| POST | `/api/chat` | Reply to a conversation `{ messages: [{role, content}] }`. Backend embeds a summary of the live database in the system prompt on every turn, so replies stay grounded without the client sending state. Returns `503` if `LLM_API_KEY` is not set. | +| POST | `/api/briefing` | One-shot executive briefing — a canned first-turn prompt. Kept for CLI / non-chat callers. | ### LLM configuration -`POST /api/briefing` uses an OpenAI-compatible chat completions endpoint: +Both `/api/chat` and `/api/briefing` use an OpenAI-compatible chat completions endpoint: ``` LLM_API_KEY required @@ -53,6 +54,6 @@ delta_command/ ├── json_db.py # atomic file I/O ├── models.py # Pydantic models (snake_case in Python, camelCase in JSON) ├── store.py # domain operations (load/save/update) -├── briefing.py # LLM-authored executive briefing +├── briefing.py # Chat + one-shot briefing via OpenAI-compatible LLM └── main.py # FastAPI routes ``` diff --git a/apps/delta-command/backend/src/delta_command/briefing.py b/apps/delta-command/backend/src/delta_command/briefing.py index c70f8c7..a9c4ec4 100644 --- a/apps/delta-command/backend/src/delta_command/briefing.py +++ b/apps/delta-command/backend/src/delta_command/briefing.py @@ -1,8 +1,8 @@ -"""LLM-generated executive briefings. +"""LLM-backed chat and executive briefing. -The backend summarises the current state into a compact prompt, then calls an -OpenAI-compatible chat completions endpoint. Works with OpenAI, Groq, Ollama, -etc. — anything that speaks the /v1/chat/completions protocol. +The backend summarises the current state into a compact system prompt, then +calls an OpenAI-compatible chat completions endpoint. Works with OpenAI, Groq, +Ollama, etc. — anything that speaks /v1/chat/completions. Configure via environment: LLM_API_KEY required (use any placeholder for local Ollama) @@ -10,6 +10,8 @@ LLM_MODEL default: gpt-4o-mini """ +from __future__ import annotations + import os import httpx @@ -17,12 +19,25 @@ from delta_command.models import Database, OpportunityStage, ProjectStatus from delta_command.store import load_database -SYSTEM_PROMPT = ( - "You are an executive briefing assistant for a delta engineering operations " - "dashboard. Given the current state, write a concise leadership briefing " - "(2-3 short paragraphs, ~150 words total) that highlights: pipeline health, " - "team capacity concerns, and delivery risks. Lead with what needs attention. " - "Use plain prose, no bullet lists, no headings." +CHAT_SYSTEM_PROMPT = ( + "You are Delta, an ops assistant for a delta engineering team. You have live " + "access to pipeline, project, and team capacity data (summarised below). " + "Answer questions concisely — usually 2–4 sentences, sometimes a short bulleted " + "list when it helps clarity. Cite specific names, numbers, and percentages from " + "the data. If a question is outside what the data can support, say so plainly." +) + +BRIEFING_SYSTEM_PROMPT = ( + "You are Delta, an ops assistant for a delta engineering team. Write a concise " + "executive briefing (2–3 short paragraphs, ~150 words total) that highlights " + "pipeline health, team capacity concerns, and delivery risks. Lead with what " + "needs attention. Cite specific names, numbers, and percentages. Use plain " + "prose — no headings, no bullet lists." +) + +BRIEFING_PROMPT = ( + "Write today's executive briefing covering pipeline health, team capacity, and " + "delivery risks." ) @@ -34,51 +49,85 @@ class LLMError(RuntimeError): """Raised when the upstream provider fails.""" +ChatMessage = dict[str, str] # {"role": "user"|"assistant", "content": str} + + def build_context(db: Database) -> str: - """Compact state summary used as the LLM user prompt.""" + """Compact state summary embedded into every chat's system prompt.""" engineers = db.engineers opps = db.opportunities projects = db.projects - active_opps = [o for o in opps if o.stage not in (OpportunityStage.WON, OpportunityStage.LOST)] - late_stage = [o for o in active_opps if o.stage in (OpportunityStage.PROPOSAL, OpportunityStage.NEGOTIATION)] + active_opps = [ + o for o in opps if o.stage not in (OpportunityStage.WON, OpportunityStage.LOST) + ] + late_stage = [ + o + for o in active_opps + if o.stage in (OpportunityStage.PROPOSAL, OpportunityStage.NEGOTIATION) + ] total_pipeline = sum(o.value for o in active_opps) weighted = sum(o.value * o.probability / 100 for o in active_opps) - avg_util = round(sum(e.utilization for e in engineers) / len(engineers)) if engineers else 0 + avg_util = ( + round(sum(e.utilization for e in engineers) / len(engineers)) if engineers else 0 + ) overallocated = sorted( (e for e in engineers if e.utilization >= 100), key=lambda e: -e.utilization, ) - unassigned = [e for e in engineers if not e.current_projects] + available = [e for e in engineers if e.utilization < 70] - active_projects = [p for p in projects if p.status in (ProjectStatus.ACTIVE, ProjectStatus.AT_RISK)] + active_projects = [ + p for p in projects if p.status in (ProjectStatus.ACTIVE, ProjectStatus.AT_RISK) + ] at_risk = [p for p in projects if p.status == ProjectStatus.AT_RISK] total_budget = sum(p.budget for p in active_projects) or 1 total_spent = sum(p.spent for p in active_projects) lines = [ f"Team: {len(engineers)} engineers, avg utilization {avg_util}%.", - f"Overallocated ({len(overallocated)}): " - + (", ".join(f"{e.name} at {e.utilization}%" for e in overallocated[:5]) or "none"), - f"Available for assignment: {len(unassigned)}.", + "Overallocated ({}): {}".format( + len(overallocated), + ", ".join(f"{e.name} at {e.utilization}%" for e in overallocated[:8]) + or "none", + ), + "Available <70% ({}): {}".format( + len(available), + ", ".join(f"{e.name} at {e.utilization}%" for e in available[:6]) + or "none", + ), "", - f"Pipeline: {len(active_opps)} active opportunities worth ${total_pipeline/1_000_000:.1f}M " - f"(${weighted/1_000_000:.1f}M weighted). {len(late_stage)} in proposal or negotiation.", + f"Pipeline: {len(active_opps)} active opportunities worth " + f"${total_pipeline/1_000_000:.1f}M " + f"(${weighted/1_000_000:.1f}M weighted). " + f"{len(late_stage)} in proposal or negotiation.", + "Top active opportunities: " + + ", ".join( + f"{o.title} ({o.client}, ${o.value/1000:.0f}K, {o.stage.value}, " + f"{o.probability}%)" + for o in sorted(active_opps, key=lambda o: -o.value)[:5] + ), "", f"Delivery: {len(active_projects)} projects active, {len(at_risk)} at risk. " - f"Budget burn: ${total_spent/1_000_000:.1f}M of ${total_budget/1_000_000:.1f}M " + f"Budget burn: ${total_spent/1_000_000:.1f}M of " + f"${total_budget/1_000_000:.1f}M " f"({round(total_spent/total_budget*100)}%).", ] if at_risk: lines.append( "At-risk projects: " - + ", ".join(f"{p.name} ({p.progress}% complete)" for p in at_risk[:3]) + + ", ".join( + f"{p.name} ({p.progress}% complete, " + f"${p.spent/1000:.0f}K of ${p.budget/1000:.0f}K)" + for p in at_risk[:5] + ) ) return "\n".join(lines) -async def call_llm(system: str, user: str) -> str: +async def call_llm(messages: list[ChatMessage]) -> str: + """Call the configured LLM with a full message list; return assistant reply.""" api_key = os.environ.get("LLM_API_KEY") if not api_key: raise LLMNotConfigured( @@ -95,10 +144,7 @@ async def call_llm(system: str, user: str) -> str: headers={"Authorization": f"Bearer {api_key}"}, json={ "model": model, - "messages": [ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - ], + "messages": messages, "temperature": 0.4, }, ) @@ -106,7 +152,9 @@ async def call_llm(system: str, user: str) -> str: raise LLMError(f"LLM request failed: {exc}") from exc if response.status_code >= 400: - raise LLMError(f"LLM provider returned {response.status_code}: {response.text[:200]}") + raise LLMError( + f"LLM provider returned {response.status_code}: {response.text[:200]}" + ) try: return response.json()["choices"][0]["message"]["content"].strip() @@ -114,5 +162,25 @@ async def call_llm(system: str, user: str) -> str: raise LLMError(f"Unexpected LLM response shape: {exc}") from exc +def _system_message(system_prompt: str) -> ChatMessage: + """System prompt + current state, formatted for the model.""" + context = build_context(load_database()) + return { + "role": "system", + "content": f"{system_prompt}\n\nCurrent state:\n{context}", + } + + +async def chat(messages: list[ChatMessage]) -> str: + """Reply to a conversation using the chat system prompt + current data.""" + return await call_llm([_system_message(CHAT_SYSTEM_PROMPT), *messages]) + + async def generate_briefing() -> str: - return await call_llm(SYSTEM_PROMPT, build_context(load_database())) + """One-shot canned briefing prompt (used by the dashboard entry point).""" + return await call_llm( + [ + _system_message(BRIEFING_SYSTEM_PROMPT), + {"role": "user", "content": BRIEFING_PROMPT}, + ] + ) diff --git a/apps/delta-command/backend/src/delta_command/main.py b/apps/delta-command/backend/src/delta_command/main.py index 587693b..a6f1ebd 100644 --- a/apps/delta-command/backend/src/delta_command/main.py +++ b/apps/delta-command/backend/src/delta_command/main.py @@ -1,12 +1,18 @@ -"""Delta Command HTTP API — a thin CRUD layer over a JSON file.""" +"""Delta Command HTTP API — a thin CRUD + chat layer over a JSON file.""" import os import uvicorn from fastapi import FastAPI, HTTPException -from delta_command.briefing import LLMError, LLMNotConfigured, generate_briefing +from delta_command.briefing import ( + LLMError, + LLMNotConfigured, + chat, + generate_briefing, +) from delta_command.models import ( + ChatRequest, Database, Opportunity, OpportunityStageUpdate, @@ -21,7 +27,7 @@ update_timeline_cell, ) -app = FastAPI(title="Delta Command API", version="1.1.0") +app = FastAPI(title="Delta Command API", version="1.2.0") @app.get("/api/state") @@ -49,14 +55,30 @@ def patch_project(body: ProjectStatusUpdate) -> Project: @app.patch("/api/timeline") def patch_timeline(body: TimelineCellUpdate) -> Database: try: - return update_timeline_cell(body.engineer_id, body.week_start, body.utilization, body.note) + return update_timeline_cell( + body.engineer_id, body.week_start, body.utilization, body.note + ) except ValueError as exc: raise HTTPException(404, str(exc)) from exc +@app.post("/api/chat") +async def post_chat(body: ChatRequest) -> dict[str, str]: + """Reply to a conversation. The system prompt embeds the current database.""" + if not body.messages: + raise HTTPException(400, "messages must not be empty") + try: + reply = await chat([m.model_dump() for m in body.messages]) + except LLMNotConfigured as exc: + raise HTTPException(503, str(exc)) from exc + except LLMError as exc: + raise HTTPException(502, str(exc)) from exc + return {"reply": reply} + + @app.post("/api/briefing") async def post_briefing() -> dict[str, str]: - """Generate an LLM-authored executive briefing from the current state.""" + """One-shot executive briefing — a canned first-turn prompt.""" try: briefing = await generate_briefing() except LLMNotConfigured as exc: diff --git a/apps/delta-command/backend/src/delta_command/models.py b/apps/delta-command/backend/src/delta_command/models.py index 8ee4782..6353c61 100644 --- a/apps/delta-command/backend/src/delta_command/models.py +++ b/apps/delta-command/backend/src/delta_command/models.py @@ -127,3 +127,17 @@ class TimelineCellUpdate(BaseModel): week_start: str utilization: int note: str | None = None + + +class ChatRole(StrEnum): + USER = "user" + ASSISTANT = "assistant" + + +class ChatTurn(BaseModel): + role: ChatRole + content: str + + +class ChatRequest(BaseModel): + messages: list[ChatTurn] diff --git a/apps/delta-command/backend/tests/test_briefing.py b/apps/delta-command/backend/tests/test_briefing.py index cbe7116..251f4ba 100644 --- a/apps/delta-command/backend/tests/test_briefing.py +++ b/apps/delta-command/backend/tests/test_briefing.py @@ -86,3 +86,69 @@ def fake_handler(request: httpx.Request) -> httpx.Response: response = client.post("/api/briefing") assert response.status_code == 502 assert "500" in response.json()["detail"] + + +def test_chat_returns_400_when_messages_empty(client: TestClient) -> None: + response = client.post("/api/chat", json={"messages": []}) + assert response.status_code == 400 + + +def test_chat_returns_503_when_llm_not_configured(client: TestClient) -> None: + response = client.post( + "/api/chat", + json={"messages": [{"role": "user", "content": "Who is overallocated?"}]}, + ) + assert response.status_code == 503 + + +def test_chat_returns_reply_and_forwards_full_history( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("LLM_API_KEY", "test-key") + monkeypatch.setenv("LLM_BASE_URL", "https://example.test/v1") + monkeypatch.setenv("LLM_MODEL", "test-model") + + captured: dict[str, Any] = {} + real_async_client = httpx.AsyncClient + + def fake_handler(request: httpx.Request) -> httpx.Response: + import json + + captured["payload"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "choices": [ + { + "message": { + "content": "Marcus, Lisa, Ryan, Sophie, and Maya are overallocated." + } + } + ] + }, + ) + + monkeypatch.setattr( + "delta_command.briefing.httpx.AsyncClient", + lambda *a, **k: real_async_client(transport=httpx.MockTransport(fake_handler)), + ) + + response = client.post( + "/api/chat", + json={ + "messages": [ + {"role": "user", "content": "Who is overallocated?"}, + ] + }, + ) + assert response.status_code == 200 + assert "Marcus" in response.json()["reply"] + + payload = captured["payload"] + assert payload["model"] == "test-model" + assert payload["messages"][0]["role"] == "system" + assert "Current state:" in payload["messages"][0]["content"] + assert payload["messages"][1] == { + "role": "user", + "content": "Who is overallocated?", + } diff --git a/apps/delta-command/src/app/chat/page.tsx b/apps/delta-command/src/app/chat/page.tsx new file mode 100644 index 0000000..4f65b84 --- /dev/null +++ b/apps/delta-command/src/app/chat/page.tsx @@ -0,0 +1,26 @@ +import { Suspense } from "react"; + +import { ChatShell } from "@/components/chat/ChatShell"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: "Chat · Delta Command", + description: "Ask Delta about pipeline, capacity, and delivery.", +}; + +/** + * Chat is a fully client-side surface — no server data fetched at page load, + * the backend pulls the current state on every /api/chat call so the answer + * is always current. + * + * Wrapped in so useSearchParams (needed for ?prompt=… deep-links) + * doesn't bail out of static rendering. + */ +export default function ChatPage() { + return ( + + + + ); +} diff --git a/apps/delta-command/src/app/dashboard/page.tsx b/apps/delta-command/src/app/dashboard/page.tsx index 6de2478..1f9fadc 100644 --- a/apps/delta-command/src/app/dashboard/page.tsx +++ b/apps/delta-command/src/app/dashboard/page.tsx @@ -6,7 +6,7 @@ import { KPICard } from "@/components/dashboard/KPICard"; import { PipelineChart } from "@/components/dashboard/PipelineChart"; import { UtilizationChart } from "@/components/dashboard/UtilizationChart"; import { ActionItemsPanel } from "@/components/dashboard/ActionItemsPanel"; -import { ExecutiveBriefingPanel } from "@/components/dashboard/ExecutiveBriefingPanel"; +import { AskDeltaCallout } from "@/components/dashboard/AskDeltaCallout"; import { MilestonesPanel } from "@/components/dashboard/MilestonesPanel"; import { ProjectCard } from "@/components/projects/ProjectCard"; import { getState } from "@/core/api"; @@ -70,7 +70,7 @@ export default async function DashboardPage() { /> - + diff --git a/apps/delta-command/src/components/chat/ChatShell.tsx b/apps/delta-command/src/components/chat/ChatShell.tsx new file mode 100644 index 0000000..9f27c13 --- /dev/null +++ b/apps/delta-command/src/components/chat/ChatShell.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Plus, Sparkles } from "lucide-react"; + +import { Composer } from "@/components/chat/Composer"; +import { Message } from "@/components/chat/Message"; +import { PromptChips } from "@/components/chat/PromptChips"; +import { postChat, type ChatTurn } from "@/core/api"; +import { + STARTER_PROMPTS, + newMessageId, + type ChatMessage, +} from "@/core/chat"; + +/** Speed of the client-side reveal animation, in characters per interval tick. */ +const REVEAL_CHARS_PER_TICK = 6; +const REVEAL_INTERVAL_MS = 24; + +/** + * The top-level chat surface — owns the message thread, composer state, and + * the client-side reveal animation for assistant replies. + * + * We intentionally keep the entire conversation in component state: threads + * are session-scoped, no persistence, no multi-thread. That matches the + * "ambient assistant" framing — every page load starts a fresh conversation. + */ +export function ChatShell() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef<{ aborted: boolean } | null>(null); + const scrollAnchorRef = useRef(null); + + const scrollToBottom = useCallback(() => { + scrollAnchorRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, loading, scrollToBottom]); + + const send = useCallback( + async (userContent: string) => { + const trimmed = userContent.trim(); + if (!trimmed || loading) return; + + const userMessage: ChatMessage = { + id: newMessageId(), + role: "user", + content: trimmed, + createdAt: Date.now(), + }; + const nextHistory: ChatTurn[] = [ + ...messages.map((m) => ({ role: m.role, content: m.content })), + { role: "user", content: trimmed }, + ]; + + setMessages((prev) => [...prev, userMessage]); + setInput(""); + setLoading(true); + setError(null); + + const abortToken = { aborted: false }; + abortRef.current = abortToken; + + try { + const { reply } = await postChat(nextHistory); + if (abortToken.aborted) return; + await revealAssistantMessage(reply, setMessages, abortToken); + } catch (err) { + if (abortToken.aborted) return; + setError(err instanceof Error ? err.message : "Chat request failed"); + } finally { + if (!abortToken.aborted) setLoading(false); + } + }, + [loading, messages], + ); + + const handleStop = useCallback(() => { + if (abortRef.current) abortRef.current.aborted = true; + setLoading(false); + setMessages((prev) => + prev.map((m) => (m.streaming ? { ...m, streaming: false } : m)), + ); + }, []); + + // Consume a ?prompt=... query param to auto-submit the first turn (used by + // the dashboard's "Ask Delta" callout). We clear the param so a refresh + // doesn't re-send. + const initialPromptRef = useRef(false); + useEffect(() => { + if (initialPromptRef.current) return; + const promptKey = searchParams.get("prompt"); + if (!promptKey) return; + initialPromptRef.current = true; + const starter = STARTER_PROMPTS.find((p) => p.id === promptKey); + if (starter) { + router.replace("/chat"); + void send(starter.prompt); + } + }, [router, searchParams, send]); + + const isEmpty = messages.length === 0 && !loading; + const lastAssistantId = useMemo( + () => [...messages].reverse().find((m) => m.role === "assistant")?.id, + [messages], + ); + + return ( +
+
+
+ + Δ + +

Delta

+ + grounded in live pipeline, team, and delivery data + +
+ {messages.length > 0 && ( + + )} +
+ +
+
+ {isEmpty ? ( + void send(p)} /> + ) : ( +
+ {messages.map((message) => ( + + ))} + {loading && !lastAssistantId && } + {error && ( +
+ {error} +
+ )} +
+ )} +
+
+
+ +
+
+ void send(input)} + onStop={handleStop} + loading={loading} + autoFocus + /> +

+ Delta reads live data on every turn; it may still be wrong. Verify before acting. +

+
+
+
+ ); +} + +function EmptyState({ onSelect }: { onSelect: (prompt: string) => void }) { + return ( +
+
+ + + AI · Delta + +
+

+ What do you need to know? +

+

+ Ask about pipeline, capacity, or delivery. Answers use the current state + of your dashboard. +

+
+ +
+
+ ); +} + +function PendingRow() { + return ( + + ); +} + +/** + * Reveal an assistant reply character-by-character on the client, so replies + * feel streamed even when the underlying HTTP call was a single JSON response. + * + * The `abortToken` lets the caller (or the Stop button) short-circuit reveal. + */ +async function revealAssistantMessage( + reply: string, + setMessages: React.Dispatch>, + abortToken: { aborted: boolean }, +): Promise { + const id = newMessageId(); + setMessages((prev) => [ + ...prev, + { + id, + role: "assistant", + content: "", + createdAt: Date.now(), + streaming: true, + }, + ]); + + return new Promise((resolve) => { + let cursor = 0; + const tick = () => { + if (abortToken.aborted) { + setMessages((prev) => + prev.map((m) => + m.id === id ? { ...m, content: reply, streaming: false } : m, + ), + ); + resolve(); + return; + } + cursor = Math.min(reply.length, cursor + REVEAL_CHARS_PER_TICK); + const slice = reply.slice(0, cursor); + setMessages((prev) => + prev.map((m) => (m.id === id ? { ...m, content: slice } : m)), + ); + if (cursor >= reply.length) { + setMessages((prev) => + prev.map((m) => (m.id === id ? { ...m, streaming: false } : m)), + ); + resolve(); + return; + } + setTimeout(tick, REVEAL_INTERVAL_MS); + }; + tick(); + }); +} diff --git a/apps/delta-command/src/components/chat/Composer.tsx b/apps/delta-command/src/components/chat/Composer.tsx new file mode 100644 index 0000000..eb80475 --- /dev/null +++ b/apps/delta-command/src/components/chat/Composer.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { ArrowUp, Square } from "lucide-react"; + +import { cn } from "@/core/utils"; + +interface ComposerProps { + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + onStop?: () => void; + disabled?: boolean; + loading?: boolean; + autoFocus?: boolean; + placeholder?: string; +} + +/** + * Multi-line composer with ⌘⏎ / ⌃⏎ submit, an inline send button, and a stop + * button while a reply is in flight. Auto-grows up to 8 rows. + */ +export function Composer({ + value, + onChange, + onSubmit, + onStop, + disabled = false, + loading = false, + autoFocus = false, + placeholder = "Ask about pipeline, capacity, or delivery…", +}: ComposerProps) { + const textareaRef = useRef(null); + + useEffect(() => { + if (autoFocus) textareaRef.current?.focus(); + }, [autoFocus]); + + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 220)}px`; + }, [value]); + + const canSend = value.trim().length > 0 && !disabled && !loading; + + function onKeyDown(event: React.KeyboardEvent) { + const isSubmit = + event.key === "Enter" && (event.metaKey || event.ctrlKey); + const isPlainEnter = + event.key === "Enter" && !event.shiftKey && !event.metaKey && !event.ctrlKey; + if (isSubmit || isPlainEnter) { + event.preventDefault(); + if (canSend) onSubmit(); + } + } + + return ( +
+