From 120ab192f10726e126f359470d71547864b3279b Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sat, 29 Aug 2026 07:49:29 -0400 Subject: [PATCH] feat: add seamless text and voice chat --- agent/agent.py | 45 +++--- agent/task_worker.py | 118 ++++++++++++++-- tests/test_background_tasks.py | 11 ++ tests/test_web_ui.py | 5 + web/app.js | 242 +++++++++++++++++++++++++++------ web/index.html | 26 +++- web/style.css | 190 +++++++++++++++++++++++++- 7 files changed, 558 insertions(+), 79 deletions(-) diff --git a/agent/agent.py b/agent/agent.py index b19f1c9..41c07d8 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -80,7 +80,8 @@ SYSTEM_PROMPT = textwrap.dedent("""\ no markdown, and no formatting except the Azure delivery markers below. - Spell out numbers when it sounds more natural ("twenty twenty-six" instead of "2026"). - - If you need to ask a question, ask exactly one. + - Make reasonable assumptions and answer directly. Ask a follow-up only + when it is genuinely impossible to proceed without one. - Be warm and direct. Don't be sycophantic or overly formal. - If you don't know something, say so briefly and move on. @@ -144,7 +145,7 @@ SYSTEM_PROMPT = textwrap.dedent("""\ - User says "look up the price of a PS5" → CALL dispatch_task with description="Find the current retail price of a PlayStation 5" After calling dispatch_task, tell the user "I'll get on that for you" - and keep the conversation going. The result comes back automatically — when + without asking for more permission or details. The result comes back automatically — when you receive it, share the findings naturally in your conversational style. You can have multiple tasks running at once. @@ -612,29 +613,41 @@ async def handle_job(ctx: JobContext) -> None: ), ) - # Listen for data messages (voice switching) from the web UI. + # Listen for conversation controls (voice selection and voice/text mode). # Defined here so it captures this job's session via closure. def _on_room_data(packet) -> None: topic = getattr(packet, "topic", None) - if topic and topic != "voice-control": + if topic and topic not in ("voice-control", "conversation-control"): return try: msg = json.loads(packet.data.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): return - if msg.get("type") != "set_voice": + + if msg.get("type") == "set_mode": + mode = msg.get("mode") + if mode not in ("voice", "text"): + return + voice_enabled = mode == "voice" + logger.info("Switching conversation mode to %s", mode) + if not voice_enabled: + asyncio.create_task(session.interrupt()) + session.input.set_audio_enabled(voice_enabled) + session.output.set_audio_enabled(voice_enabled) return - voice = msg.get("voice", "") - if not voice: - return - logger.info("Switching TTS voice to %s", voice) - try: - tts = session.tts - if hasattr(tts, "update_options"): - tts.update_options(voice=voice) - logger.info("Voice updated to %s", voice) - except Exception as e: # noqa: BLE001 - logger.warning("Failed to update voice: %s", e) + + if msg.get("type") == "set_voice": + voice = msg.get("voice", "") + if not voice: + return + logger.info("Switching TTS voice to %s", voice) + try: + active_tts = session.tts + if hasattr(active_tts, "update_options"): + active_tts.update_options(voice=voice) + logger.info("Voice updated to %s", voice) + except Exception as e: # noqa: BLE001 + logger.warning("Failed to update voice: %s", e) ctx.room.on("data_received", _on_room_data) diff --git a/agent/task_worker.py b/agent/task_worker.py index 1d5bbaf..5ddde26 100644 --- a/agent/task_worker.py +++ b/agent/task_worker.py @@ -65,8 +65,8 @@ class TaskWorker: model=model, messages=messages, tools=tools or None, - max_tokens=500, - temperature=0.7, + max_tokens=700, + temperature=0.3, extra_body={"chat_template_kwargs": {"enable_thinking": False}}, ) except Exception as e: @@ -102,18 +102,72 @@ class TaskWorker: if not text: reg.fail(self.task_id, "LLM returned empty response") return + if self._is_deferred_answer(text): + reg.add_step( + self.task_id, + "thinking", + "Draft deferred the work; continuing automatically.", + ) + if iteration == max_iterations - 1: + reg.fail(self.task_id, "Could not produce a complete answer") + return + messages.extend( + [ + {"role": "assistant", "content": text}, + { + "role": "user", + "content": ( + "Do not ask me a follow-up or offer to continue later. " + "Continue the task now, run any more targeted tool calls " + "you need, make reasonable assumptions, and only finish " + "when you have directly answered the original request." + ), + }, + ] + ) + continue reg.add_step(self.task_id, "text", text) reg.complete(self.task_id, text) return reg.fail(self.task_id, f"Reached max iterations ({max_iterations})") + @staticmethod + def _is_deferred_answer(text: str) -> bool: + """Reject drafts that ask permission or defer the requested work.""" + lowered = text.lower() + deferral_phrases = ( + "would you like me", + "do you want me", + "should i ", + "shall i ", + "if you'd like", + "if you would like", + "let me know if", + "i need to perform", + "i need more information", + "i can refine", + "i can search", + "i can provide", + "need a more targeted search", + ) + return "?" in text or any(phrase in lowered for phrase in deferral_phrases) + def _system_prompt(self) -> str: + today = datetime.now(timezone.utc).strftime("%B %d, %Y") return ( - "You are Hope's research assistant working on a background task. " - "Use your tools to gather information thoroughly. When you have " - "enough, provide a final answer in 1-3 sentences suitable for " - "speaking aloud. Plain text only, no markdown or formatting." + "You are Hope's autonomous research assistant working on a background task. " + f"Today is {today}. Use your tools until you have directly completed the request. " + "Never ask the user a follow-up question, ask permission to search again, or offer " + "to finish later; make reasonable assumptions and continue autonomously. If a web " + "search is broad or inconclusive, immediately run a more targeted query and scrape " + "the most promising result pages, including today's date and specific reputable " + "sources when freshness matters. For headline " + "requests, return concrete named events rather than links to news homepages, and " + "respect any requested item count exactly. Only " + "produce a final answer once it fulfills the original request. The final answer must " + "be 1-3 concise sentences suitable for speaking aloud, with plain text only and no " + "markdown or formatting." ) def _load_tools(self) -> list[dict]: @@ -157,7 +211,7 @@ class TaskWorker: "type": "function", "function": { "name": "web_search", - "description": "Search the web for current information using Firecrawl.", + "description": "Search the web for current information. Returns titles, descriptions, and URLs.", "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, @@ -165,6 +219,18 @@ class TaskWorker: }, }, }, + { + "type": "function", + "function": { + "name": "web_scrape", + "description": "Read the full content of a promising URL returned by web_search.", + "parameters": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + }, + }, + }, ] async def _execute_tool(self, name: str, args: dict) -> str: @@ -227,18 +293,46 @@ class TaskWorker: async with httpx.AsyncClient(timeout=30) as client: resp = await client.post( f"{firecrawl_base}/v1/search", - json={"query": query, "limit": 3}, + json={"query": query, "limit": 5}, ) if resp.status_code == 200: data = resp.json() items = data.get("data", []) - lines = [ - f"{r.get('title', '')}: {r.get('description', '')}" - for r in items[:3] - ] + lines = [] + for item in items[:5]: + title = (item.get("title") or "Untitled").strip() + description = (item.get("description") or "").strip() + url = (item.get("url") or "").strip() + lines.append(f"{title}: {description}") + if url: + lines.append(f"URL: {url}") return "\n".join(lines) or "No results found." return f"Search failed (HTTP {resp.status_code})." + elif name == "web_scrape": + url = args.get("url", "") + if not url: + return "A URL is required." + firecrawl_base = os.environ.get("FIRECRAWL_BASE", "") + if not firecrawl_base: + return "Web scraping is not configured." + async with httpx.AsyncClient(timeout=90) as client: + resp = await client.post( + f"{firecrawl_base}/v1/scrape", + json={"url": url, "formats": ["markdown"], "timeout": 60000}, + ) + if resp.status_code != 200: + return f"Scrape failed (HTTP {resp.status_code})." + data = resp.json() + if not data.get("success"): + return f"Scrape failed: {data.get('error', 'unknown error')}" + result = data.get("data", {}) + markdown = (result.get("markdown") or "").strip() + if not markdown: + return "No page content was extracted." + title = (result.get("metadata", {}).get("title") or "").strip() + return f"Page: {title}\n\n{markdown[:8000]}" + else: return f"Unknown tool: {name}" diff --git a/tests/test_background_tasks.py b/tests/test_background_tasks.py index 6a54a9e..9f1f633 100644 --- a/tests/test_background_tasks.py +++ b/tests/test_background_tasks.py @@ -12,6 +12,7 @@ import pytest sys.path.insert(0, str(Path(__file__).parents[1] / "agent")) from task_registry import TaskRegistry # noqa: E402 from task_router import should_force_background_task # noqa: E402 +from task_worker import TaskWorker # noqa: E402 @pytest.mark.parametrize( @@ -46,6 +47,16 @@ def test_direct_requests_do_not_force_dispatch(utterance): assert not should_force_background_task(utterance) +def test_deferred_task_answers_are_rejected(): + assert TaskWorker._is_deferred_answer( + "I need to perform a more targeted search. Would you like me to continue?" + ) + assert TaskWorker._is_deferred_answer("I can refine this if you'd like.") + assert not TaskWorker._is_deferred_answer( + "Reuters reports that the agreement was signed today, while the BBC says voting begins Monday." + ) + + def test_task_event_directories_are_isolated(tmp_path): first = TaskRegistry(tmp_path / "room-one") second = TaskRegistry(tmp_path / "room-two") diff --git a/tests/test_web_ui.py b/tests/test_web_ui.py index add6d10..15348c6 100644 --- a/tests/test_web_ui.py +++ b/tests/test_web_ui.py @@ -12,12 +12,17 @@ def test_index_serves(client): assert "Hope" in body assert "livekit-client.umd.js" in body assert "app.js" in body + assert 'id="voiceModeBtn"' in body + assert 'id="textModeBtn"' in body + assert 'id="chatComposer"' in body def test_app_js_serves(client): resp = client.get("/app.js") assert resp.status_code == 200 assert "LiveKitClient" in resp.text or "Room" in resp.text + assert 'topic: "lk.chat"' in resp.text + assert "sendModeMessage" in resp.text def test_style_css_serves(client): diff --git a/web/app.js b/web/app.js index 544ead1..e1ea166 100644 --- a/web/app.js +++ b/web/app.js @@ -26,6 +26,7 @@ const VOICES = [ const HISTORY_KEY = "voice_history"; const HISTORY_MAX = 50; +const MODE_KEY = "hope_conversation_mode"; // ── State ─────────────────────────────────────────────────────────────────── let room = null; @@ -35,6 +36,8 @@ let micLevel = 0; // 0..1, smoothed let agentAudioLevel = 0; // 0..1, smoothed let userSpeaking = false; // local VAD-ish flag from mic level let wakeLock = null; +let conversationMode = localStorage.getItem(MODE_KEY) === "text" ? "text" : "voice"; +let connectionPromise = null; function escapeHtml(s) { const d = document.createElement("div"); @@ -65,6 +68,13 @@ const tasksPanel = document.getElementById("tasksPanel"); const tasksList = document.getElementById("tasksList"); const tasksBadge = document.getElementById("tasksBadge"); const closeTasksBtn = document.getElementById("closeTasksBtn"); +const voiceModeBtn = document.getElementById("voiceModeBtn"); +const textModeBtn = document.getElementById("textModeBtn"); +const modeDescriptionEl = document.getElementById("modeDescription"); +const transcriptModeLabelEl = document.getElementById("transcriptModeLabel"); +const chatComposer = document.getElementById("chatComposer"); +const chatInput = document.getElementById("chatInput"); +const sendChatBtn = document.getElementById("sendChatBtn"); // ── Background tasks state ────────────────────────────────────────────────── const tasks = new Map(); // task_id -> {id, description, status, steps: [], result, error} @@ -133,6 +143,57 @@ tasksBtn.setAttribute("aria-expanded", "false"); tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open"))); closeTasksBtn.addEventListener("click", () => setTasksOpen(false)); +// ── Voice / text mode ────────────────────────────────────────────────────── +function updateModeUI() { + const isText = conversationMode === "text"; + document.body.dataset.mode = conversationMode; + voiceModeBtn.classList.toggle("active", !isText); + textModeBtn.classList.toggle("active", isText); + voiceModeBtn.setAttribute("aria-pressed", String(!isText)); + textModeBtn.setAttribute("aria-pressed", String(isText)); + modeDescriptionEl.textContent = isText + ? "Type when quiet is better. Switch back to voice without losing the thread." + : "Speak naturally. I’ll listen, remember, and answer in real time."; + transcriptModeLabelEl.textContent = isText ? "Shared voice and text history" : "Live voice transcript"; + startBtn.innerHTML = isText + ? 'Connect to Hope' + : 'Start conversation'; + if (isText) { + setState(room ? "listening" : "idle"); + window.setTimeout(() => chatInput.focus(), 0); + } +} + +async function setConversationMode(mode) { + if (mode !== "voice" && mode !== "text") return; + conversationMode = mode; + localStorage.setItem(MODE_KEY, mode); + updateModeUI(); + + if (!room || !room.localParticipant) return; + sendModeMessage(mode); + if (mode === "text") { + await room.localParticipant.setMicrophoneEnabled(false); + micMuted = true; + muteBtn.disabled = true; + updateMuteIcon(); + stopVisualLoop(); + statusEl.textContent = "Secure room · Text mode"; + } else { + await room.startAudio(); + await room.localParticipant.setMicrophoneEnabled(true); + micMuted = false; + muteBtn.disabled = false; + updateMuteIcon(); + setupMicAnalyser(); + startVisualLoop(); + statusEl.textContent = "Secure room · Microphone on"; + } +} + +voiceModeBtn.addEventListener("click", () => setConversationMode("voice")); +textModeBtn.addEventListener("click", () => setConversationMode("text")); + // ── Audio analysers (mic + remote agent audio) ───────────────────────────── let micCtx = null; let micAnalyser = null; @@ -202,18 +263,26 @@ function readAgentAudioLevel() { // ── State machine ─────────────────────────────────────────────────────────── const STATE_LABELS = { - idle: "Ready when you are", - connecting: "Opening a secure room", - listening: "Listening", - thinking: "Thinking it through", - speaking: "Hope is speaking", + voice: { + idle: "Ready when you are", + connecting: "Opening a secure room", + listening: "Listening", + thinking: "Thinking it through", + speaking: "Hope is speaking", + }, + text: { + idle: "Ready to type", + connecting: "Opening a secure room", + listening: "Text chat ready", + thinking: "Hope is writing", + speaking: "Hope replied", + }, }; function setState(next) { - if (next === uiState) return; uiState = next; orbEl.dataset.state = next; - stateLabelEl.textContent = STATE_LABELS[next] || next; + stateLabelEl.textContent = STATE_LABELS[conversationMode][next] || next; // Thinking timer: show the "thinking..." chip if it lingers past 2s if (next === "thinking") { @@ -235,6 +304,8 @@ function setState(next) { } } +updateModeUI(); + function inferState() { if (!room || !room.localParticipant) return "idle"; if (!agentParticipant) return "connecting"; @@ -441,6 +512,10 @@ function handleDataPacket(payload, participant, topic) { const msg = JSON.parse(new TextDecoder().decode(payload)); if (msg.type === "transcript") { addMessage(msg.role || "agent", msg.text); + if (conversationMode === "text" && (msg.role || "agent") === "agent") { + setState("listening"); + chatInput.focus(); + } } else if (msg.type === "task_event") { handleTaskEvent(msg); } @@ -558,9 +633,12 @@ function findAgent(rm) { function markAgentFound(rm, participant) { agentParticipant = participant; - statusEl.textContent = "Hope joined · Preparing microphone…"; - // Send current voice selection to the agent + statusEl.textContent = conversationMode === "text" + ? "Hope joined · Text chat ready" + : "Hope joined · Preparing microphone…"; + // Synchronize voice and interaction mode when the agent joins. sendVoiceMessage(currentVoiceId(), rm); + sendModeMessage(conversationMode, rm); } function createRoom(token) { @@ -665,52 +743,116 @@ function waitForAgent(timeoutMs) { }); } -startBtn.addEventListener("click", async () => { - try { - setState("connecting"); - statusEl.textContent = "Opening your secure room…"; - await acquireWakeLock(); +async function connectConversation() { + if (room && agentParticipant) return room; + if (connectionPromise) return connectionPromise; - // Get a signed access token from the token endpoint - const token = await fetchToken(ROOM_NAME); + connectionPromise = (async () => { + try { + setState("connecting"); + statusEl.textContent = "Opening your secure room…"; + await acquireWakeLock(); - room = await createRoom(token); - await room.startAudio(); + // Get a signed access token from the token endpoint. + const token = await fetchToken(ROOM_NAME); + room = await createRoom(token); - // The agent is dispatched when we join. If it misses the dispatch - // (e.g. the server just started and the worker wasn't ready yet), - // reconnect once to trigger a fresh dispatch. - statusEl.textContent = "Inviting Hope into the room…"; - if (!(await waitForAgent(10000))) { - statusEl.textContent = "Hope is taking a moment · Retrying…"; - const stale = room; - room = null; // detach first so its Disconnected handler - agentParticipant = null; // cannot clobber the UI - await stale.disconnect(); - const freshToken = await fetchToken(ROOM_NAME); - room = await createRoom(freshToken); - if (!(await waitForAgent(15000))) { - throw new Error("Assistant did not join — reload the page and try again"); + // The agent is dispatched when we join. If it misses the dispatch + // (for example during a server restart), reconnect once. + statusEl.textContent = "Inviting Hope into the room…"; + if (!(await waitForAgent(10000))) { + statusEl.textContent = "Hope is taking a moment · Retrying…"; + const stale = room; + room = null; + agentParticipant = null; + await stale.disconnect(); + const freshToken = await fetchToken(ROOM_NAME); + room = await createRoom(freshToken); + if (!(await waitForAgent(15000))) { + throw new Error("Assistant did not join — reload the page and try again"); + } } + + sendModeMessage(conversationMode); + if (conversationMode === "voice") { + await room.startAudio(); + statusEl.textContent = "Waiting for microphone permission…"; + await room.localParticipant.setMicrophoneEnabled(true); + micMuted = false; + setupMicAnalyser(); + startVisualLoop(); + statusEl.textContent = "Secure room · Microphone on"; + } else { + await room.localParticipant.setMicrophoneEnabled(false); + micMuted = true; + stopVisualLoop(); + statusEl.textContent = "Secure room · Text mode"; + } + + startBtn.disabled = true; + stopBtn.disabled = false; + muteBtn.disabled = conversationMode !== "voice"; + updateMuteIcon(); + setState("listening"); + return room; + } catch (err) { + console.error("Connection failed:", err); + statusEl.textContent = `Error: ${err.message}`; + setState("idle"); + releaseWakeLock(); + throw err; + } finally { + connectionPromise = null; } + })(); - statusEl.textContent = "Waiting for microphone permission…"; - await room.localParticipant.setMicrophoneEnabled(true); + return connectionPromise; +} - statusEl.textContent = "Secure room · Microphone on"; - startBtn.disabled = true; - stopBtn.disabled = false; - muteBtn.disabled = false; - micMuted = false; +startBtn.addEventListener("click", () => { + connectConversation().catch(() => {}); +}); - setupMicAnalyser(); - startVisualLoop(); +// ── Text chat ────────────────────────────────────────────────────────────── +function resizeChatInput() { + chatInput.style.height = "auto"; + chatInput.style.height = `${Math.min(chatInput.scrollHeight, 144)}px`; +} +chatInput.addEventListener("input", () => { + resizeChatInput(); + sendChatBtn.disabled = !chatInput.value.trim(); +}); + +chatInput.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + chatComposer.requestSubmit(); + } +}); + +chatComposer.addEventListener("submit", async (event) => { + event.preventDefault(); + const text = chatInput.value.trim(); + if (!text) return; + + sendChatBtn.disabled = true; + chatInput.disabled = true; + try { + if (conversationMode !== "text") await setConversationMode("text"); + await connectConversation(); + setState("thinking"); + await room.localParticipant.sendText(text, { topic: "lk.chat" }); + chatInput.value = ""; + resizeChatInput(); } catch (err) { - console.error("Connection failed:", err); - statusEl.textContent = `Error: ${err.message}`; - setState("idle"); - releaseWakeLock(); + console.error("Text message failed:", err); + statusEl.textContent = `Message not sent: ${err.message}`; + setState("listening"); + } finally { + chatInput.disabled = false; + sendChatBtn.disabled = !chatInput.value.trim(); + chatInput.focus(); } }); @@ -770,3 +912,13 @@ function sendVoiceMessage(voice, targetRoom) { reliable: true, }); } + +function sendModeMessage(mode, targetRoom) { + const r = targetRoom || room; + if (!r || !r.localParticipant) return; + const payload = new TextEncoder().encode(JSON.stringify({ type: "set_mode", mode })); + r.localParticipant.publishData(payload, { + topic: "conversation-control", + reliable: true, + }); +} diff --git a/web/index.html b/web/index.html index 09303c5..43286b8 100644 --- a/web/index.html +++ b/web/index.html @@ -9,7 +9,7 @@ - +
@@ -18,11 +18,21 @@

Hope

-

Voice companion

+

Voice and text companion

+
+ + +
Private to Shane @@ -39,7 +49,7 @@

I'm here.

-

Speak naturally. I’ll listen, remember, and answer in real time.

+

Speak naturally. I’ll listen, remember, and answer in real time.

@@ -85,11 +95,19 @@

Conversation

-

Live transcript

+

Live voice transcript

+
+ + + + Enter to send · Shift+Enter for a new line +
diff --git a/web/style.css b/web/style.css index a59eda9..56d6f16 100644 --- a/web/style.css +++ b/web/style.css @@ -86,6 +86,18 @@ button { transform: translateY(0); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .app-shell { width: min(1180px, 100%); margin: 0 auto; @@ -171,6 +183,50 @@ button { gap: 0.65rem; } +.mode-switch { + height: 38px; + display: flex; + align-items: center; + gap: 2px; + padding: 3px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(5, 11, 20, 0.55); +} + +.mode-option { + height: 30px; + display: inline-flex; + align-items: center; + gap: 0.38rem; + padding: 0 0.65rem; + border: 0; + border-radius: 7px; + cursor: pointer; + color: var(--ink-muted); + background: transparent; + font-size: 0.72rem; + font-weight: 650; + transition: color 160ms ease, background 160ms ease, box-shadow 160ms ease; +} + +.mode-option:hover { + color: var(--ink-soft); +} + +.mode-option.active { + color: var(--ink); + background: var(--surface-soft); + box-shadow: 0 1px 5px rgba(2, 7, 14, 0.3); +} + +.mode-option:focus-visible, +.chat-composer textarea:focus-visible, +.send-chat:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + .identity-chip { min-height: 38px; gap: 0.55rem; @@ -804,6 +860,116 @@ button { transition: height 70ms linear; } +/* ── Text composer ──────────────────────────────────────────────────────── */ +.chat-composer { + position: relative; + display: none; + align-items: flex-end; + gap: 0.55rem; + padding: 0.75rem 0 1.25rem; + border-top: 1px solid var(--line); +} + +body[data-mode="text"] .chat-composer { + display: flex; +} + +.chat-composer textarea { + width: 100%; + min-height: 48px; + max-height: 144px; + resize: none; + padding: 0.8rem 3.2rem 0.8rem 0.9rem; + border: 1px solid var(--line-strong); + border-radius: 12px; + color: var(--ink); + background: var(--surface-raised); + font: inherit; + font-size: 0.9rem; + line-height: 1.4; + caret-color: var(--iris); + transition: border-color 160ms ease, background 160ms ease; +} + +.chat-composer textarea::placeholder { + color: var(--ink-muted); +} + +.chat-composer textarea:focus { + border-color: rgba(139, 167, 255, 0.56); + background: var(--surface-soft); +} + +.send-chat { + position: absolute; + right: 0.55rem; + bottom: 2.05rem; + width: 34px; + height: 34px; + display: grid; + place-items: center; + border: 0; + border-radius: 9px; + cursor: pointer; + color: #08111f; + background: var(--iris); + transition: opacity 150ms ease, transform 150ms var(--ease-out), background 150ms ease; +} + +.send-chat:hover:not(:disabled) { + background: #a9bbff; + transform: translateY(-1px); +} + +.send-chat:disabled { + cursor: default; + opacity: 0.25; +} + +.composer-hint { + position: absolute; + left: 0.2rem; + bottom: 0.25rem; + color: var(--ink-muted); + font-size: 0.62rem; +} + +/* Text mode makes the transcript the primary surface while preserving Hope's + live presence and the same underlying conversation. */ +body[data-mode="text"] .workspace { + grid-template-columns: minmax(240px, 0.62fr) minmax(480px, 1.38fr); +} + +body[data-mode="text"] .voice-console { + padding: 2rem 1.5rem 1.4rem; +} + +body[data-mode="text"] .console-intro h2 { + font-size: 1.55rem; +} + +body[data-mode="text"] .orb-stage { + min-height: 220px; +} + +body[data-mode="text"] .orb { + width: 180px; + height: 180px; + transform: scale(0.72); +} + +body[data-mode="text"] .controls { + max-width: 220px; +} + +body[data-mode="text"] #muteBtn { + display: none; +} + +body[data-mode="text"] .mic-meter { + visibility: hidden; +} + /* ── Voice settings ─────────────────────────────────────────────────────── */ .sheet-backdrop { position: fixed; @@ -1065,7 +1231,8 @@ button { calc(0.85rem + env(safe-area-inset-left)); } - .workspace { + .workspace, + body[data-mode="text"] .workspace { grid-template-columns: 1fr; height: auto; min-height: auto; @@ -1096,10 +1263,15 @@ button { } .brand p, - .identity-chip { + .identity-chip, + .mode-option svg { display: none; } + .mode-option { + padding: 0 0.55rem; + } + .workspace { border-right: 0; border-left: 0; @@ -1111,6 +1283,20 @@ button { padding: 2.1rem 1.15rem 1.25rem; } + body[data-mode="text"] .voice-console { + min-height: 270px; + padding: 1.35rem 1rem 1rem; + } + + body[data-mode="text"] .orb-stage { + min-height: 120px; + margin: -0.75rem 0; + } + + body[data-mode="text"] .orb { + transform: scale(0.48); + } + .console-intro h2 { font-size: 1.7rem; }