Skip to content

Instantly share code, notes, and snippets.

@bluntbrain
Created May 16, 2026 12:21
Show Gist options
  • Select an option

  • Save bluntbrain/cd62251d2701ab4f8025f2327ebba11d to your computer and use it in GitHub Desktop.

Select an option

Save bluntbrain/cd62251d2701ab4f8025f2327ebba11d to your computer and use it in GitHub Desktop.
Talkamore Quiet Mode — detailed backend and frontend implementation prompts

Quiet Mode — Backend Implementation Prompt

Overview

Implement a "Quiet Mode" feature that allows users to toggle into a one-way journaling state. When Quiet Mode is active, the chatbot (Maya) stops responding to user messages entirely — the user can dump thoughts without conversational interruptions. All messages are still persisted. When the user exits Quiet Mode, everything written during the session is summarized and stored in the memory/remembering system with a "Quiet Mode" tag.


1. Database Schema Changes

1.1 Add quietModeActive to the User model

model User {
  // ... existing fields ...
  quietModeActive   Boolean   @default(false)
  quietModeStartedAt DateTime?
  // incremented counter for each quiet-mode session to create unique tags
  quietModeSessionCount Int  @default(0)
}

Migration name: add_quiet_mode

Reasoning: Quiet mode is per-user, not per-conversation. A user who enables it in Telegram should also be in quiet mode on web/mobile. The quietModeSessionCount allows tagging summaries as "Quiet Mode #1", "Quiet Mode #2", etc.

1.2 Add quietModeSessionId to the ChatMessage model

model ChatMessage {
  // ... existing fields ...
  quietModeSessionId Int?  // null for normal messages; set to User.quietModeSessionCount for quiet-mode messages
}

This lets us efficiently query all messages belonging to a specific quiet-mode session at exit time without relying on timestamps.


2. API Endpoints

2.1 POST /api/me/quiet-mode/toggle

Toggle Quiet Mode on/off.

Auth: JWT required.

Request body:

{
  "enable": true  // true = enter quiet mode, false = exit quiet mode
}

Response (200) — entering quiet mode:

{
  "quietModeActive": true,
  "startedAt": "2026-05-16T12:30:00.000Z",
  "sessionNumber": 3
}

Response (200) — exiting quiet mode (summary generated):

{
  "quietModeActive": false,
  "sessionNumber": 3,
  "messageCount": 14,
  "summary": "You wrote about feeling stuck at work, a tense conversation with your manager, and excitement about an upcoming trip to Goa...",
  "summaryStored": true
}

Implementation logic:

Entering quiet mode:

  1. Verify user is authenticated and onboarded.
  2. If already active, return 409 Conflict.
  3. Atomically increment quietModeSessionCount and set quietModeActive = true, quietModeStartedAt = now().
  4. Evict all user pi sessions (evictUserSessions) — the next turn after quiet mode will need a fresh context.
  5. Track analytics event: quiet_mode_entered.
  6. Return the new session number.

Exiting quiet mode:

  1. Verify user is authenticated and quiet mode is currently active.
  2. Query all ChatMessage rows where quietModeSessionId === user.quietModeSessionCount (the current session), ordered by createdAt ASC.
  3. If message count is 0, just disable quiet mode and return empty summary.
  4. Build a summarization prompt using the collected messages.
  5. Call the LLM (use same model as regular chat, or a cheaper one for summarization — gpt-4o-mini is fine) with a prompt like:
You are summarizing a "Quiet Mode" journaling session. The user wrote these thoughts without expecting a response. Summarize what they wrote in a first-person narrative (as if the user wrote it themselves). Include key themes, emotions, people mentioned, decisions, and recurring patterns. Keep it concise (3-6 paragraphs). Do NOT address the user or offer advice — this is a personal summary.

Messages:
[timestamp] message 1
[timestamp] message 2
...

Output the summary as plain text.
  1. Store the summary:
    • Call addDocument to SuperMemory with type: "quiet_mode", metadata: { sessionNumber, tag: "Quiet Mode", messageCount, startedAt, endedAt }.
    • Optionally create a JournalBlock with source: CHAT_SESSION (or a new QUIET_MODE source if you want distinct treatment) containing the summary and raw transcript.
  2. Reset quietModeActive = false, quietModeStartedAt = null.
  3. Track analytics: quiet_mode_exited with messageCount and summaryLength.
  4. Return the summary and metadata.

2.2 GET /api/me/quiet-mode/status

Returns current quiet mode status. No auth required beyond JWT.

Response (200):

{
  "quietModeActive": true,
  "startedAt": "2026-05-16T12:30:00.000Z",
  "sessionNumber": 3,
  "messageCount": 7
}

3. Chat Pipeline Changes

3.1 Modify processChat in src/lib/chat.ts

At the top of processChat (after assertCanSend but before building the session), add a quiet-mode short-circuit:

// Quiet Mode: persist the user message but do NOT generate an assistant reply.
if (user.quietModeActive) {
  const userMessageId = await persistMessage(
    user.id,
    conversationId,
    "USER",
    text,
    user.quietModeSessionCount, // new param — see below
  );

  // Increment the flush counter so messages still get flushed to journal.
  // But do NOT trigger the LLM path — no session, no reply, no usage event.
  const updated = await db.user.update({
    where: { id: user.id },
    data: { unflushedCount: { increment: 1 } },
    select: { unflushedCount: true },
  });
  if (updated.unflushedCount >= FLUSH_THRESHOLD) {
    setImmediate(() => {
      flushUserBuffer(user.id).catch((err) => {
        log.error("background flush threw (quiet mode)", { userId: user.id, err });
      });
    });
  }

  // Return an empty reply. The caller (bot handler / http endpoint) must handle
  // the "no reply" case — the bot should just stay silent, the http endpoint
  // should return `{ reply: "", quietMode: true }`.
  return {
    reply: "",
    bubbles: [],
    turnId: randomUUID(),
    userMessageId,
    quietMode: true,
  };
}

3.2 Modify persistMessage signature

Add an optional quietModeSessionId parameter:

async function persistMessage(
  userId: string,
  conversationId: string,
  role: "USER" | "ASSISTANT",
  content: string,
  quietModeSessionId?: number,
): Promise<string> {
  // ... existing logic, add quietModeSessionId to the create data ...
}

3.3 Modify ChatResult return type

Add quietMode?: boolean to the return type so callers know whether a reply was intentionally empty.


4. Telegram Bot Handler Changes

4.1 Modify handleChat in src/bot/handlers.ts

After processChat returns, check for quietMode:

const result = await processChat({ user, conversationId, text, images });

if (result.quietMode) {
  // Don't send any reply. Don't even send "typing".
  // The user is in quiet mode — silence is the feature.
  return { userMessageId: result.userMessageId };
}

// Normal flow: send the reply
await ctx.reply(result.reply);

Also skip the ctx.sendChatAction("typing") call when quiet mode is active (check user.quietModeActive before calling it).

4.2 Add /quiet command

Register a /quiet command in registerHandlers:

bot.command("quiet", async (ctx) => {
  const user = await identifyOrCreate(ctx);
  if (!user.onboardedAt) {
    await ctx.reply("let's finish setting up first, then you can use quiet mode.");
    return;
  }

  if (user.quietModeActive) {
    // Exit quiet mode — delegate to the toggle logic
    // Show a quick "summarizing..." message, then the summary
    await ctx.reply("exiting quiet mode, give me a moment to summarize...");
    const summary = await exitQuietMode(user.id); // shared function
    if (summary) {
      await ctx.reply(`here's what you covered:\n\n${summary}`);
    } else {
      await ctx.reply("quiet mode off. nothing was written this session.");
    }
  } else {
    // Enter quiet mode
    await enterQuietMode(user.id); // shared function
    await ctx.reply("quiet mode on. i'll stay quiet — write whenever you're ready. use /quiet again when you're done.");
  }
});

4.3 Add quiet mode exit detection via natural language

Optionally, detect exit intent from user messages during quiet mode. If the user sends something like "exit quiet mode", "done quiet mode", "stop quiet mode", or "/quiet", exit automatically. This is a UX nicety:

const EXIT_PATTERNS = [
  /^exit quiet mode$/i,
  /^stop quiet mode$/i,
  /^end quiet mode$/i,
  /^done quiet mode$/i,
  /^\/quiet$/i,
];

function isQuietModeExitIntent(text: string): boolean {
  const trimmed = text.trim().toLowerCase();
  return EXIT_PATTERNS.some((p) => p.test(trimmed));
}

If matched during quiet mode, trigger the exit + summary flow instead of persisting the exit command as a message.


5. HTTP Chat Endpoint Changes

5.1 Modify handleChatSend in src/api/chat.ts

Same short-circuit pattern as the Telegram handler:

const result = await processChat({ user, conversationId, text: effectiveMessage, images: images?.map(r => r.pi) });

if (result.quietMode) {
  sendJson(res, 200, {
    conversationId,
    reply: "",
    persona: user.persona,
    quietMode: true,
    userMessageId: result.userMessageId,
    attachments: [],
  });
  return;
}

5.2 Modify handleChatStream in src/api/chat.ts

For streaming, when quiet mode is active:

  • Don't send any deltas
  • Just send the done frame with quietMode: true and empty reply
if (result.quietMode) {
  writeFrame({
    type: "done",
    conversationId,
    reply: "",
    turnId: result.turnId,
    persona: user.persona,
    quietMode: true,
    userMessageId: result.userMessageId,
    attachments: [],
  });
  safeEnd();
  return;
}

6. Shared Quiet Mode Logic

Create src/lib/quiet-mode.ts with the shared enter/exit functions used by both the HTTP toggle endpoint and the Telegram /quiet command:

// src/lib/quiet-mode.ts

import { db } from "./db.js";
import { log } from "./log.js";
import { track } from "./analytics.js";
import { addDocument } from "./supermemory.js";
import { decryptForUser } from "./crypto.js";
import { evictUserSessions } from "./pi-runtime.js";
import { generateSummary } from "./summarize.js"; // or a dedicated summarizer

export async function enterQuietMode(userId: string): Promise<{
  sessionNumber: number;
  startedAt: Date;
}> {
  const user = await db.user.update({
    where: { id: userId },
    data: {
      quietModeActive: true,
      quietModeStartedAt: new Date(),
      quietModeSessionCount: { increment: 1 },
    },
    select: {
      quietModeSessionCount: true,
      quietModeStartedAt: true,
    },
  });

  evictUserSessions(userId);
  track("quiet_mode_entered", { sessionNumber: user.quietModeSessionCount }, userId);
  log.info("quiet mode: entered", { userId, sessionNumber: user.quietModeSessionCount });

  return {
    sessionNumber: user.quietModeSessionCount,
    startedAt: user.quietModeStartedAt!,
  };
}

export async function exitQuietMode(userId: string): Promise<{
  sessionNumber: number;
  messageCount: number;
  summary: string | null;
}> {
  const user = await db.user.findUniqueOrThrow({
    where: { id: userId },
    select: { quietModeSessionCount: true, quietModeActive: true },
  });

  if (!user.quietModeActive) {
    throw new Error("Quiet mode is not active");
  }

  const sessionNumber = user.quietModeSessionCount;

  // Fetch all messages from this quiet mode session
  const messages = await db.chatMessage.findMany({
    where: {
      userId,
      quietModeSessionId: sessionNumber,
      role: "USER",
    },
    orderBy: { createdAt: "asc" },
    select: { content: true, createdAt: true },
  });

  const messageCount = messages.length;
  let summary: string | null = null;

  if (messageCount > 0) {
    // Decrypt and format messages
    const decryptedMessages = await Promise.all(
      messages.map(async (m) => ({
        timestamp: m.createdAt.toISOString(),
        content: await decryptForUser(userId, m.content),
      }))
    );

    const transcript = decryptedMessages
      .map((m) => `[${m.timestamp}] ${m.content}`)
      .join("\n\n");

    // Generate summary
    summary = await generateQuietModeSummary(userId, transcript, sessionNumber);

    // Store in SuperMemory
    try {
      await addDocument({
        userId,
        content: summary ?? transcript.slice(0, 2000),
        customId: `quiet_mode_${userId}_${sessionNumber}`,
        type: "quiet_mode",
        metadata: {
          sessionNumber,
          messageCount,
          tag: "Quiet Mode",
          date: new Date().toISOString().slice(0, 10),
        },
      });
      log.info("quiet mode: summary stored in supermemory", {
        userId,
        sessionNumber,
        messageCount,
      });
    } catch (err) {
      log.error("quiet mode: supermemory store failed", {
        userId,
        sessionNumber,
        err: err instanceof Error ? err.message : String(err),
      });
    }

    // Optionally create a JournalBlock for the quiet mode session
    try {
      // ... JournalBlock creation logic ...
    } catch (err) {
      log.error("quiet mode: journal block creation failed", {
        userId,
        sessionNumber,
        err: err instanceof Error ? err.message : String(err),
      });
    }
  }

  // Disable quiet mode
  await db.user.update({
    where: { id: userId },
    data: {
      quietModeActive: false,
      quietModeStartedAt: null,
    },
  });

  track("quiet_mode_exited", {
    sessionNumber,
    messageCount,
    hasSummary: !!summary,
  }, userId);

  log.info("quiet mode: exited", { userId, sessionNumber, messageCount });

  return { sessionNumber, messageCount, summary };
}

6.1 Summarization prompt

Create the summarization function:

async function generateQuietModeSummary(
  userId: string,
  transcript: string,
  sessionNumber: number,
): Promise<string> {
  // Use gpt-4o-mini or the cheapest capable model for cost efficiency.
  // This is a batch job, not a real-time turn.
  const prompt = `You are summarizing a "Quiet Mode" journaling session from a journaling app called Talkamore. During quiet mode, the user writes freely without receiving any responses — it's a one-way brain dump.

Summarize what they wrote in a concise first-person narrative (as if the user was reflecting back on what they wrote). Include:
- Key themes and topics
- Emotional tone and shifts
- People or relationships mentioned
- Decisions, dilemmas, or realizations
- Recurring patterns (if any are visible)

Style: reflective, grounded, no cheerleading. Do NOT address the user ("you"). Do NOT offer advice. This is a personal summary for the user's own memory. Write in the user's voice.

Session #${sessionNumber}:
${transcript.slice(0, 8000)}

Summary:`;

  // Call the LLM — reuse the existing OpenAI/Anthropic client patterns from pi-runtime.ts
  const response = await callModelForSummary(prompt);
  return response.trim();
}

7. Edge Cases & Error Handling

Scenario Handling
User tries to enter quiet mode while already in it Return 409 Conflict, no-op
User tries to exit but isn't in quiet mode Return 400, no-op
Zero messages written during quiet mode Exit silently, no summary generated, no SuperMemory write
LLM call fails during summarization Fall back to storing the raw transcript in SuperMemory; log error
SuperMemory write fails during exit Log error, still disable quiet mode, return partial success
User sends /quiet during onboarding Reject with message to finish onboarding first
User changes persona during quiet mode Allow it — quiet mode is persona-agnostic
User accesses web app while quiet mode is active on Telegram Quiet mode is user-global — web UI should also show quiet mode state and not generate replies

8. Testing Checklist

  • Enter quiet mode via /quiet command on Telegram
  • Send messages — verify no responses come back
  • Send messages — verify they're persisted in chat_messages with correct quietModeSessionId
  • Exit quiet mode — verify summary is generated
  • Exit quiet mode — verify summary is stored in SuperMemory with "Quiet Mode" tag
  • Enter/exit via HTTP API endpoints
  • Zero-message quiet mode session (enter then immediately exit)
  • Multi-hundred message quiet mode session (test summarization at scale)
  • Quiet mode survives server restart (state is in DB)
  • Analytics events fire correctly
  • Existing chat functionality is unaffected when quiet mode is off
  • Concurrent web + Telegram quiet mode consistency

9. Files Modified (Summary)

File Change
backend/prisma/schema.prisma Add quietModeActive, quietModeStartedAt, quietModeSessionCount to User; add quietModeSessionId to ChatMessage
backend/src/lib/quiet-mode.ts New file — enter/exit logic, summarization
backend/src/lib/chat.ts Add quiet-mode short-circuit in processChat; update persistMessage
backend/src/bot/handlers.ts Add /quiet command; skip reply for quiet-mode turns
backend/src/api/chat.ts Handle empty replies with quietMode: true flag
backend/src/api/me-preferences.ts (or new route file) Add POST toggle + GET status endpoints
backend/src/api/server.ts Register new routes

Quiet Mode — Frontend Implementation Prompt

Overview

Implement the "Quiet Mode" UI for Talkamore's web chat/journal interface. Quiet Mode is a one-way journaling state where the chatbot (Maya) stops responding — the user dumps thoughts without conversational interruptions. When the user exits, everything gets summarized and stored.

This spec covers the web frontend (talkamore-frontend). The mobile app (talkamore-mobile) should follow the same patterns but is out of scope for this prompt.

Design reference: Talkamore uses a warm, literary aesthetic — Newsreader serif for headings/editorial text, Manrope sans-serif for UI controls. Gold/oxblood/cream color palette. The quiet mode UI should feel like a natural extension of this, not a jarring mode switch.


1. Component Architecture

New Files

app/(journal)/journal/new/
  QuietModeToggle.tsx        # The toggle button rendered in the chat bar
  QuietModeBanner.tsx        # Persistent banner shown when quiet mode is active
  QuietModeExitDialog.tsx    # Modal/dialog shown when user clicks to exit
  QuietModeSummary.tsx       # Inline card showing the generated summary

hooks/
  use-quiet-mode.ts          # React hook wrapping the quiet mode API

Modified Files

app/(journal)/journal/new/
  AIChatView.tsx             # Add QuietModeToggle to chat input area; handle quiet mode message rendering
  page.tsx                   # Maybe: show QuietModeBanner globally when active
  atoms.ts                   # Add quiet mode atoms

lib/
  api-client.ts              # Add quiet mode API methods

2. State Management (Jotai Atoms)

Add to atoms.ts:

// Quiet Mode
export const quietModeActiveAtom = atom<boolean>(false);
export const quietModeSessionNumberAtom = atom<number>(0);
export const quietModeMessageCountAtom = atom<number>(0);
export const quietModeSummaryAtom = atom<string | null>(null);
export const quietModeBannerDismissedAtom = atom<boolean>(false);

3. API Client (lib/api-client.ts)

Add these methods:

// Toggle quiet mode on/off
export async function toggleQuietMode(enable: boolean): Promise<{
  quietModeActive: boolean;
  startedAt?: string;
  sessionNumber: number;
  messageCount?: number;
  summary?: string;
  summaryStored?: boolean;
}> {
  const res = await fetch(`${API_BASE}/me/quiet-mode/toggle`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
    body: JSON.stringify({ enable }),
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(err.error || "Failed to toggle quiet mode");
  }
  return res.json();
}

// Get current quiet mode status
export async function getQuietModeStatus(): Promise<{
  quietModeActive: boolean;
  startedAt?: string;
  sessionNumber: number;
  messageCount: number;
}> {
  const res = await fetch(`${API_BASE}/me/quiet-mode/status`, {
    headers: { Authorization: `Bearer ${getToken()}` },
  });
  if (!res.ok) throw new Error("Failed to get quiet mode status");
  return res.json();
}

4. Hook: use-quiet-mode.ts

"use client";

import { useCallback } from "react";
import { useAtom, useSetAtom } from "jotai";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toggleQuietMode, getQuietModeStatus } from "@/lib/api-client";
import {
  quietModeActiveAtom,
  quietModeSessionNumberAtom,
  quietModeMessageCountAtom,
  quietModeSummaryAtom,
} from "./atoms";
import { toast } from "sonner";
import { track } from "@/lib/analytics";

export function useQuietMode() {
  const [active, setActive] = useAtom(quietModeActiveAtom);
  const [sessionNumber, setSessionNumber] = useAtom(quietModeSessionNumberAtom);
  const [messageCount, setMessageCount] = useAtom(quietModeMessageCountAtom);
  const setSummary = useSetAtom(quietModeSummaryAtom);

  // Fetch status on mount
  const statusQuery = useQuery({
    queryKey: ["quiet-mode-status"],
    queryFn: getQuietModeStatus,
    refetchOnWindowFocus: true,
    staleTime: 30_000,
  });

  // Sync server state into atoms
  // (useEffect that watches statusQuery.data)

  const enterMutation = useMutation({
    mutationFn: () => toggleQuietMode(true),
    onSuccess: (data) => {
      setActive(true);
      setSessionNumber(data.sessionNumber);
      setMessageCount(0);
      setSummary(null);
      track("quiet_mode_entered", { sessionNumber: data.sessionNumber });
      toast("Quiet mode on — I'll stay quiet while you write.", {
        description: "Use the button or type 'exit quiet mode' when you're done.",
      });
    },
    onError: (err) => {
      toast.error(err instanceof Error ? err.message : "Couldn't enable quiet mode");
    },
  });

  const exitMutation = useMutation({
    mutationFn: () => toggleQuietMode(false),
    onSuccess: (data) => {
      setActive(false);
      setMessageCount(data.messageCount ?? 0);
      setSummary(data.summary ?? null);
      track("quiet_mode_exited", {
        sessionNumber: data.sessionNumber,
        messageCount: data.messageCount,
      });
      toast.success(`Quiet mode off — ${data.messageCount} messages summarized.`);
    },
    onError: (err) => {
      toast.error(err instanceof Error ? err.message : "Couldn't disable quiet mode");
    },
  });

  const enter = useCallback(() => enterMutation.mutate(), [enterMutation]);
  const exit = useCallback(() => exitMutation.mutate(), [exitMutation]);

  return {
    active,
    sessionNumber,
    messageCount,
    enter,
    exit,
    isEntering: enterMutation.isPending,
    isExiting: exitMutation.isPending,
  };
}

5. Component: QuietModeToggle.tsx

The toggle button rendered in the chat input toolbar.

Location: app/(journal)/journal/new/QuietModeToggle.tsx

Visual design:

  • Off state: A subtle text button or icon button in the chat bar (next to attachment/voice controls). Shows a moon or "quiet" icon. Clicking it enters quiet mode.
  • On state (active): Visually distinct — filled/active state. Could use a pulsing subtle animation or a different color to indicate "recording/active". Clicking it opens the exit confirmation dialog.
"use client";

import { motion, AnimatePresence } from "framer-motion";
import { Moon, MoonStar } from "lucide-react";
import { useQuietMode } from "@/hooks/use-quiet-mode";
import { useState } from "react";
import { QuietModeExitDialog } from "./QuietModeExitDialog";
import { BTN, BT2, TX } from "@/lib/landing-theme";

export function QuietModeToggle() {
  const { active, enter, isEntering, exit, isExiting } = useQuietMode();
  const [showExitDialog, setShowExitDialog] = useState(false);

  const handleClick = () => {
    if (active) {
      setShowExitDialog(true);
    } else {
      enter();
    }
  };

  return (
    <>
      <button
        onClick={handleClick}
        disabled={isEntering || isExiting}
        className={`
          relative flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm
          transition-all duration-200
          ${active
            ? "bg-oxblood/10 text-oxblood border border-oxblood/20"
            : "text-gray-400 hover:text-gray-600 hover:bg-gray-100"
          }
        `}
        title={active ? "Exit Quiet Mode" : "Enter Quiet Mode"}
      >
        {active ? (
          <motion.div
            initial={{ scale: 0.8 }}
            animate={{ scale: [1, 1.1, 1] }}
            transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
          >
            <MoonStar className="w-4 h-4" />
          </motion.div>
        ) : (
          <Moon className="w-4 h-4" />
        )}
        <span className="hidden sm:inline">
          {active ? "Quiet" : "Quiet Mode"}
        </span>

        {/* Active indicator dot */}
        {active && (
          <span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-oxblood rounded-full">
            <span className="absolute inset-0 bg-oxblood rounded-full animate-ping" />
          </span>
        )}
      </button>

      <QuietModeExitDialog
        open={showExitDialog}
        onOpenChange={setShowExitDialog}
        onConfirm={() => {
          exit();
          setShowExitDialog(false);
        }}
        isLoading={isExiting}
      />
    </>
  );
}

6. Component: QuietModeBanner.tsx

A persistent, dismissible banner shown at the top of the chat area when quiet mode is active. Communicates the current state and message count.

Location: app/(journal)/journal/new/QuietModeBanner.tsx

Visual: A soft cream/amber band with a subtle left border accent. Not red/warning — this is intentional, not an error state.

"use client";

import { motion, AnimatePresence } from "framer-motion";
import { MoonStar, X } from "lucide-react";
import { useAtom } from "jotai";
import { quietModeBannerDismissedAtom, quietModeMessageCountAtom } from "./atoms";

export function QuietModeBanner() {
  const [dismissed, setDismissed] = useAtom(quietModeBannerDismissedAtom);
  const [messageCount] = useAtom(quietModeMessageCountAtom);

  if (dismissed) return null;

  return (
    <AnimatePresence>
      <motion.div
        initial={{ height: 0, opacity: 0 }}
        animate={{ height: "auto", opacity: 1 }}
        exit={{ height: 0, opacity: 0 }}
        className="
          mx-4 mt-3 px-4 py-2.5 rounded-lg
          bg-amber-50/80 border-l-2 border-amber-300
          flex items-center justify-between gap-3
        "
      >
        <div className="flex items-center gap-2.5 text-sm text-amber-800">
          <MoonStar className="w-4 h-4 flex-shrink-0" />
          <span>
            Quiet mode is on — I'm listening but won't respond.
            {messageCount > 0 && (
              <span className="ml-1 text-amber-600">
                ({messageCount} {messageCount === 1 ? "note" : "notes"} so far)
              </span>
            )}
          </span>
        </div>
        <button
          onClick={() => setDismissed(true)}
          className="text-amber-400 hover:text-amber-600 transition-colors flex-shrink-0"
          aria-label="Dismiss banner"
        >
          <X className="w-4 h-4" />
        </button>
      </motion.div>
    </AnimatePresence>
  );
}

7. Component: QuietModeExitDialog.tsx

A modal confirmation dialog shown when the user clicks to exit quiet mode.

Location: app/(journal)/journal/new/QuietModeExitDialog.tsx

Design: Talkamore uses clean, minimal dialogs. This should be warm and reassuring — not an "are you sure?" panic dialog. More like "ready to see what you wrote?"

"use client";

import { Moon, Loader2 } from "lucide-react";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";

interface Props {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  onConfirm: () => void;
  isLoading: boolean;
}

export function QuietModeExitDialog({ open, onOpenChange, onConfirm, isLoading }: Props) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-md">
        <DialogHeader>
          <DialogTitle className="flex items-center gap-2">
            <Moon className="w-5 h-5 text-oxblood" />
            Exit Quiet Mode
          </DialogTitle>
          <DialogDescription className="text-gray-500 pt-2">
            I'll summarize everything you wrote into a personal reflection and save it to your memory. You can always start another quiet mode session later.
          </DialogDescription>
        </DialogHeader>
        <DialogFooter className="gap-2 sm:gap-0">
          <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
            Stay in quiet mode
          </Button>
          <Button
            onClick={onConfirm}
            disabled={isLoading}
            className="bg-oxblood hover:bg-oxblood/90 text-white"
          >
            {isLoading ? (
              <>
                <Loader2 className="w-4 h-4 mr-2 animate-spin" />
                Summarizing...
              </>
            ) : (
              "Exit & summarize"
            )}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

8. Component: QuietModeSummary.tsx

Shown inline in the chat after exiting quiet mode. Displays the generated summary as a rich card.

Location: app/(journal)/journal/new/QuietModeSummary.tsx

Design: A distinct card that feels like a "memory artifact." Cream background, subtle border, a "Quiet Mode #3" tag. Uses Newsreader serif for the body text. Should feel like a thoughtful recap, not a system message.

"use client";

import { motion } from "framer-motion";
import { Moon, BookOpen, X } from "lucide-react";
import { useAtom } from "jotai";
import { quietModeSummaryAtom, quietModeSessionNumberAtom, quietModeMessageCountAtom } from "./atoms";
import { Newsreader } from "next/font/google";

const newsreader = Newsreader({ subsets: ["latin"], style: ["italic", "normal"] });

export function QuietModeSummary() {
  const [summary, setSummary] = useAtom(quietModeSummaryAtom);
  const [sessionNumber] = useAtom(quietModeSessionNumberAtom);
  const [messageCount] = useAtom(quietModeMessageCountAtom);

  if (!summary) return null;

  return (
    <motion.div
      initial={{ opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.4, ease: "easeOut" }}
      className="mx-4 my-4"
    >
      <div className="
        relative rounded-xl border border-stone-200 bg-cream/60
        p-5 shadow-sm
      ">
        {/* Header */}
        <div className="flex items-center justify-between mb-4">
          <div className="flex items-center gap-2">
            <span className="
              inline-flex items-center gap-1.5 px-2.5 py-1
              rounded-full text-xs font-medium
              bg-amber-100 text-amber-700
            ">
              <Moon className="w-3 h-3" />
              Quiet Mode #{sessionNumber}
            </span>
            <span className="text-xs text-gray-400">
              {messageCount} {messageCount === 1 ? "note" : "notes"}
            </span>
          </div>
          <button
            onClick={() => setSummary(null)}
            className="text-gray-300 hover:text-gray-500 transition-colors"
          >
            <X className="w-4 h-4" />
          </button>
        </div>

        {/* Summary body */}
        <div className={newsreader.className}>
          <p className="text-[15px] leading-relaxed text-stone-700 whitespace-pre-line">
            {summary}
          </p>
        </div>

        {/* Footer */}
        <div className="mt-4 pt-3 border-t border-stone-200/60 flex items-center gap-1.5 text-xs text-gray-400">
          <BookOpen className="w-3 h-3" />
          Saved to your memory
        </div>
      </div>
    </motion.div>
  );
}

9. Integration: Modified AIChatView.tsx

9.1 Add quiet mode to the chat input toolbar

Inside AIChatView, locate the input area (the floating pill/bar at the bottom containing the text input, send button, attachment buttons, etc.). Add the QuietModeToggle component to the toolbar, positioned to the left of the attachment buttons.

// Inside AIChatView's toolbar area:
<div className="flex items-center gap-2">
  <QuietModeToggle />
  {/* ... existing attachment, voice, send buttons ... */}
</div>

9.2 Handle quiet mode in message sending

When quietModeActiveAtom is true:

  1. Don't show the "typing..." / loading state after sending a message. The message just appears and stays there.
  2. Keep the input enabled — the whole point is to keep writing.
  3. Increment the local message counter (quietModeMessageCountAtom) for each sent message so the banner shows the running count.
  4. The streaming hook (useChatSend) already handles the empty reply case from the backend — when the server returns reply: "" and quietMode: true, the optimistic assistant placeholder should be removed (no assistant bubble shown).

9.3 Handle the /chat/send response

In the useChatSend hook (or wherever the send mutation lives), when the response includes quietMode: true:

if (response.quietMode) {
  // Remove the pending assistant bubble from the optimistic state
  // Don't show any "..." or loading indicator
  // Just keep the user message in the chat
  return;
}

9.4 Show exit intent detection hint

After the user sends a few messages in quiet mode, show a subtle hint below the input:

{active && messageCount > 3 && (
  <p className="text-xs text-gray-400 text-center mt-2">
    Type "exit quiet mode" or click the moon when you're done
  </p>
)}

9.5 Detect exit intent in chat input

When the user types "exit quiet mode", "stop quiet mode", or "done quiet mode" (case-insensitive match), intercept the message before sending and trigger the exit flow instead:

const EXIT_PHRASES = ["exit quiet mode", "stop quiet mode", "end quiet mode", "done quiet mode"];

if (active && EXIT_PHRASES.includes(text.trim().toLowerCase())) {
  exit();
  return;
}

10. Integration: page.tsx (or the parent layout)

10.1 Show QuietModeBanner globally

When quietModeActiveAtom is true, render QuietModeBanner above the main content area. This ensures the banner is visible regardless of which view (chat/journal/remembering) the user is on.

10.2 Show QuietModeSummary in chat

When quietModeSummaryAtom is non-null, render QuietModeSummary in the chat message area as an ephemeral card (dismissible, not a permanent message).


11. Mobile Considerations

11.1 Mobile toolbar layout

On mobile (< sm breakpoint), the chat toolbar is tighter. The QuietModeToggle should show only the icon (no "Quiet Mode" text label) and be positioned to the left of the attachment button:

[🌙] [📎] [🎤] [input.................] [➤]

11.2 Mobile exit dialog

The QuietModeExitDialog should be a bottom sheet on mobile, not a centered modal. Use the existing MobileToolsSheet pattern or a standard Drawer component.

11.3 Mobile summary card

On mobile, QuietModeSummary should be full-width with slightly less padding. The dismissal X button should be larger (44px touch target).


12. Styling Reference

Use Talkamore's existing design tokens from lib/landing-theme.ts:

Token Value Usage
OXBLOOD #9B2C2C Active state, toggle indicator, confirm buttons
GOLD_SOFT amber tones Banner background, summary tag
PAPER_BG_TRANSLUCENT cream Summary card background
TX primary text Summary body
TM muted text Banner text, helper hints
BTN button defaults Toggle button off state
BT2 button hover Toggle button hover state

13. States & Edge Cases

State Behavior
Loading (entering) Toggle button shows spinner or is disabled; toast announces entry
Loading (exiting) Exit dialog shows spinner on confirm button; chat is non-interactive during summarization
Empty session (0 messages) No summary generated; no summary card shown; just silently exit
Network error on toggle Toast error; quiet mode state stays unchanged; user can retry
Server returns no summary Exit succeeds but no summary card shown; toast says how many messages were saved
User switches tabs while in quiet mode Quiet mode is global — banner shows on all views; status is refetched on window focus
User refreshes page in quiet mode getQuietModeStatus is called on mount; state is restored from server
Very long summary (>500 words) Summary card is scrollable with a max-height and fade-out gradient at the bottom
Back-to-back quiet mode sessions Each session gets its own summary card; old cards can be dismissed

14. Analytics Events

Track these events via the existing track() function:

track("quiet_mode_toggle_clicked", { current_state: active ? "on" : "off" });
track("quiet_mode_entered", { sessionNumber });
track("quiet_mode_exited", { sessionNumber, messageCount });
track("quiet_mode_summary_viewed", { sessionNumber });
track("quiet_mode_summary_dismissed", { sessionNumber });
track("quiet_mode_exit_intent_typed", {}); // when user types "exit quiet mode" instead of clicking

15. Accessibility

  • Toggle button: aria-label="Enter Quiet Mode" / aria-label="Exit Quiet Mode" (changes with state)
  • Toggle button: aria-pressed={active} for screen readers
  • Banner: role="status" aria-live="polite" so screen readers announce the state change
  • Exit dialog: focus trap, Escape to close, aria-labelledby
  • Summary card: role="article" aria-label="Quiet Mode session summary"
  • All interactive elements: minimum 44x44px touch targets on mobile

16. Testing Checklist

  • Toggle enters quiet mode — button state changes, banner appears
  • Send messages in quiet mode — no assistant response, no typing indicator
  • Message counter in banner increments correctly
  • "Exit quiet mode" typed in input triggers exit flow
  • Exit dialog appears on button click
  • Exit generates summary — summary card appears in chat
  • Summary card is dismissible
  • Summary card body renders markdown/newlines correctly
  • Empty session exits cleanly (no summary)
  • Toggle works in mobile viewport
  • Quiet mode state persists across page refresh
  • Quiet mode state is consistent with server (status endpoint)
  • Keyboard navigation works (Tab, Enter, Escape)
  • Screen reader announces state changes
  • Analytics events fire for enter, exit, dismiss
  • Toast notifications appear for enter/exit
  • Toggle is disabled while enter/exit mutation is in flight
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment