diff --git a/cypress/e2e/auth.cy.js b/cypress/e2e/auth.cy.js index 48750ba..9f58366 100644 --- a/cypress/e2e/auth.cy.js +++ b/cypress/e2e/auth.cy.js @@ -87,7 +87,8 @@ describe("Authentication", () => { it("A-11: Sign out clears state and returns to API key screen", () => { cy.goToMainApp(); - cy.get(".app-signout-btn").click(); + // Sign out button is now in the sidebar + cy.contains("Sign out").click(); // With mock user, signOut clears apiKey but user persists (no real Firebase listener). // App shows API key setup screen since user exists but apiKey is empty. cy.get(".setup-card").should("exist"); diff --git a/cypress/e2e/main-app.cy.js b/cypress/e2e/main-app.cy.js index 282a7bf..76ecf0f 100644 --- a/cypress/e2e/main-app.cy.js +++ b/cypress/e2e/main-app.cy.js @@ -45,8 +45,9 @@ describe("Main App - Output Box", () => { cy.goToMainApp(); }); - it("O-1: shows placeholder text when empty", () => { - cy.contains("Your formatted text will appear here").should("be.visible"); + it("O-1: shows empty state when no output", () => { + cy.get(".chat-empty").should("be.visible"); + cy.contains("Tap the mic to start recording").should("be.visible"); }); it("O-2: displays formatted text after recording", () => { @@ -76,54 +77,51 @@ describe("Main App - Output Box", () => { cy.get(".export-modal").should("be.visible"); }); - it("O-5: action buttons disabled when no output", () => { - cy.get('.output-icon-btn[title="Copy text"]').should("be.disabled"); - cy.get('.output-icon-btn[title="Read aloud"]').should("be.disabled"); - cy.get('.output-icon-btn[title="Export"]').should("be.disabled"); + it("O-5: action buttons not visible when no output (empty state shown)", () => { + // When there's no output, the empty state is shown instead of OutputBox + cy.get(".chat-empty").should("be.visible"); + cy.get(".output-icon-btn").should("not.exist"); }); }); -describe("Main App - History", () => { +describe("Main App - Conversations", () => { beforeEach(() => { cy.goToMainApp(); }); - it("H-1: history section hidden when no items exist", () => { - cy.get(".history-list").should("not.exist"); + it("H-1: no conversations in sidebar when none exist", () => { + cy.get(".sidebar-convo-btn").should("not.exist"); + cy.contains("No conversations yet").should("exist"); }); - it("H-2: history appears after first recording with timestamp", () => { + it("H-2: conversation appears in sidebar after first recording", () => { cy.get(".mic-btn").click(); cy.get(".mic-btn").click(); cy.get(".output-text", { timeout: 5000 }).should("be.visible"); - cy.get(".history-list").should("be.visible"); - cy.get(".history-item").should("have.length", 1); - cy.get(".history-item-time").should("be.visible"); + cy.get(".sidebar-convo-btn").should("have.length", 1); }); - it("H-3: clicking a history item restores its text to OutputBox", () => { + it("H-3: clicking a conversation restores its text to OutputBox", () => { // Create a recording first cy.get(".mic-btn").click(); cy.get(".mic-btn").click(); cy.get(".output-text", { timeout: 5000 }).should("be.visible"); - // Get the text content - cy.get(".output-text").invoke("text").then((originalText) => { - // Click the history item - cy.get(".history-item").first().click(); - cy.get(".output-text").should("contain.text", originalText.substring(0, 50)); - }); + // Start a new conversation (clears output) + cy.get(".sidebar-new-btn").click(); + cy.get(".chat-empty").should("be.visible"); + + // Click the sidebar conversation to restore + cy.get(".sidebar-convo-btn").first().click(); + cy.get(".output-text").should("be.visible"); }); - it("H-4: stores up to 5 items, oldest dropped on overflow", () => { - // Create 6 recordings - for (let i = 0; i < 6; i++) { - cy.get(".mic-btn").click(); - cy.get(".mic-btn").click(); - cy.get(".output-text", { timeout: 5000 }).should("be.visible"); - // Wait for processing to finish before next recording - cy.contains("Tap to speak", { timeout: 5000 }).should("be.visible"); - } - cy.get(".history-item").should("have.length", 5); + it("H-4: new chat button clears output", () => { + cy.get(".mic-btn").click(); + cy.get(".mic-btn").click(); + cy.get(".output-text", { timeout: 5000 }).should("be.visible"); + + cy.get(".sidebar-new-btn").click(); + cy.get(".chat-empty").should("be.visible"); }); }); diff --git a/cypress/e2e/settings.cy.js b/cypress/e2e/settings.cy.js index 5c3509f..6325ace 100644 --- a/cypress/e2e/settings.cy.js +++ b/cypress/e2e/settings.cy.js @@ -1,19 +1,24 @@ +// Helper to open settings from sidebar +const openSettings = () => { + cy.get(".sidebar-footer-btn").first().click(); +}; + describe("Settings Modal", () => { beforeEach(() => { cy.goToMainApp(); }); - it("S-1: gear icon only visible on main app screen", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').should("be.visible"); + it("S-1: settings button visible in sidebar on main app screen", () => { + cy.contains("Settings").should("exist"); }); - it("S-2: opens when gear icon is clicked", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + it("S-2: opens when settings button is clicked", () => { + openSettings(); cy.get(".settings-modal").should("be.visible"); }); it("S-3: three sidebar tabs render (Account, Formats, About)", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.get(".settings-tab-btn").should("have.length", 3); cy.contains("Account").should("be.visible"); cy.contains("Formats").should("be.visible"); @@ -21,21 +26,21 @@ describe("Settings Modal", () => { }); it("S-4: closes on overlay click", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.get(".settings-modal").should("be.visible"); cy.get(".settings-overlay").click({ force: true }); cy.get(".settings-modal").should("not.exist"); }); it("S-5: closes on Escape key", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.get(".settings-modal").should("be.visible"); cy.get("body").type("{esc}"); cy.get(".settings-modal").should("not.exist"); }); it("S-6: closes on X button", () => { - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.get(".settings-modal").should("be.visible"); cy.get(".settings-close-btn").click(); cy.get(".settings-modal").should("not.exist"); @@ -45,7 +50,7 @@ describe("Settings Modal", () => { describe("Settings - Account Tab", () => { beforeEach(() => { cy.goToMainApp(); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); }); it("SA-1: displays user name and email", () => { @@ -58,7 +63,8 @@ describe("Settings - Account Tab", () => { }); it("SA-3: password reset button hidden for Google users", () => { - // Re-visit with a Google provider user + // Close current modal and re-visit with a Google provider user + cy.get(".settings-close-btn").click(); cy.visit("/", { onBeforeLoad(win) { win.__CYPRESS_MOCK_USER__ = { @@ -72,7 +78,7 @@ describe("Settings - Account Tab", () => { win.localStorage.setItem("sidekick_apikey_google-user-456", "sk-test-key-1234567890"); }, }); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.contains("Reset password").should("not.exist"); }); @@ -114,7 +120,7 @@ describe("Settings - Account Tab", () => { describe("Settings - Formats Tab", () => { beforeEach(() => { cy.goToMainApp(); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.contains("Formats").click(); }); @@ -150,7 +156,7 @@ describe("Settings - Formats Tab", () => { // Close and reopen settings cy.get(".settings-close-btn").click(); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.contains("Formats").click(); // Should still be toggled off @@ -161,7 +167,7 @@ describe("Settings - Formats Tab", () => { describe("Settings - About Tab", () => { beforeEach(() => { cy.goToMainApp(); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + openSettings(); cy.contains("About").click(); }); diff --git a/cypress/e2e/theme-header.cy.js b/cypress/e2e/theme-header.cy.js index 5a1ca71..cce1e7e 100644 --- a/cypress/e2e/theme-header.cy.js +++ b/cypress/e2e/theme-header.cy.js @@ -19,9 +19,9 @@ describe("Theme", () => { }); it("T-3: theme persists across page refresh", () => { - // Go to main app and switch to light mode + // Go to main app and switch to light mode via sidebar settings cy.goToMainApp(); - cy.get('.header-icon-btn[aria-label="Settings"]').click(); + cy.get(".sidebar-footer-btn").first().click(); cy.get(".settings-toggle").last().click(); cy.get("body").should("not.have.class", "dark"); @@ -34,9 +34,9 @@ describe("Theme", () => { }); }); -describe("Header", () => { - it("HD-1: logo renders on all screens", () => { - // Welcome screen +describe("Header & Sidebar", () => { + it("HD-1: logo/branding renders on all screens", () => { + // Welcome screen - header logo cy.visit("/", { onBeforeLoad(win) { win.localStorage.removeItem("sidekick_seen_welcome"); @@ -45,12 +45,12 @@ describe("Header", () => { cy.get(".header-logo").should("be.visible"); cy.get(".header-logo-name").should("contain.text", "Sidekick"); - // Auth screen (skip welcome, no user) + // Auth screen - header logo cy.skipWelcome(); cy.visit("/"); cy.get(".header-logo").should("be.visible"); - // API key screen (has user, no key) + // API key screen - header logo cy.visit("/", { onBeforeLoad(win) { win.localStorage.setItem("sidekick_seen_welcome", "1"); @@ -65,29 +65,29 @@ describe("Header", () => { }); cy.get(".header-logo").should("be.visible"); - // Main app screen + // Main app screen - sidebar exists cy.goToMainApp(); - cy.get(".header-logo").should("be.visible"); + cy.get(".sidebar").should("exist"); }); - it("HD-2: gear icon hidden on welcome screen", () => { + it("HD-2: sidebar hidden on welcome screen", () => { cy.visit("/", { onBeforeLoad(win) { win.localStorage.removeItem("sidekick_seen_welcome"); }, }); cy.get(".welcome-card").should("be.visible"); - cy.get('.header-icon-btn[aria-label="Settings"]').should("not.exist"); + cy.get(".sidebar").should("not.exist"); }); - it("HD-3: gear icon hidden on auth screen", () => { + it("HD-3: sidebar hidden on auth screen", () => { cy.skipWelcome(); cy.visit("/"); cy.get(".auth-card").should("be.visible"); - cy.get('.header-icon-btn[aria-label="Settings"]').should("not.exist"); + cy.get(".sidebar").should("not.exist"); }); - it("HD-4: gear icon hidden on API key screen", () => { + it("HD-4: sidebar hidden on API key screen", () => { cy.visit("/", { onBeforeLoad(win) { win.localStorage.setItem("sidekick_seen_welcome", "1"); @@ -101,11 +101,12 @@ describe("Header", () => { }, }); cy.get(".setup-card").should("be.visible"); - cy.get('.header-icon-btn[aria-label="Settings"]').should("not.exist"); + cy.get(".sidebar").should("not.exist"); }); - it("HD-5: gear icon visible on main app screen", () => { + it("HD-5: sidebar with settings visible on main app screen", () => { cy.goToMainApp(); - cy.get('.header-icon-btn[aria-label="Settings"]').should("be.visible"); + cy.get(".sidebar").should("exist"); + cy.contains("Settings").should("exist"); }); }); diff --git a/src/App.css b/src/App.css index 279091f..06a3b83 100644 --- a/src/App.css +++ b/src/App.css @@ -7,75 +7,164 @@ flex-direction: column; } -.app-hero { - padding: 48px 0 36px; - text-align: center; +/* ── Chat layout (main app with sidebar) ── */ +.app-wrap--chat { + max-width: none; + margin: 0; + padding: 0; + flex-direction: row; + height: 100vh; + overflow: hidden; } -.app-hero-title { - font-family: 'Instrument Serif', serif; - font-size: clamp(36px, 9vw, 48px); - font-weight: 400; - line-height: 1.1; - letter-spacing: -0.02em; - margin-bottom: 10px; - color: var(--text); +.chat-main { + flex: 1; + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + min-width: 0; + background: var(--bg); } -.app-hero-title em { - font-style: italic; +/* ── Top bar (hamburger + title) ── */ +.chat-topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + flex-shrink: 0; +} + +.chat-topbar-toggle { + width: 36px; + height: 36px; + border-radius: 8px; + border: none; + background: transparent; color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s, color 0.2s; } -.app-hero-sub { +.chat-topbar-toggle:hover { + background: var(--surface2); + color: var(--text); +} + +.chat-topbar-toggle.hidden { + opacity: 0; + pointer-events: none; +} + +.chat-topbar-title { font-size: 13px; + font-weight: 500; color: var(--text-muted); - letter-spacing: 0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Scrollable content area ── */ +.chat-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + padding: 0 24px; } -.app-screen { +.chat-content-inner { + max-width: 720px; + width: 100%; + margin: 0 auto; + flex: 1; display: flex; flex-direction: column; - gap: 16px; - padding-top: 24px; - animation: fadeUp 0.35s ease both; } -.app-footer { +/* ── Bottom input bar ── */ +.chat-inputbar { + padding: 16px 24px 24px; + flex-shrink: 0; display: flex; + justify-content: center; + background: var(--bg); +} + +.chat-inputbar-inner { + max-width: 720px; + width: 100%; +} + +/* ── Empty state in chat ── */ +.chat-empty { + flex: 1; + display: flex; + flex-direction: column; align-items: center; - justify-content: space-between; - padding-top: 8px; + justify-content: center; + gap: 12px; + opacity: 0.5; + padding: 48px 24px; } -.app-footer-key { +.chat-empty-logo { + width: 48px; + height: 48px; + background: var(--text); + color: var(--surface); + border-radius: 14px; display: flex; align-items: center; - gap: 5px; - font-size: 11px; + justify-content: center; + transition: background 0.3s, color 0.3s; +} + +.chat-empty-text { + font-size: 15px; color: var(--text-muted); + text-align: center; } -.app-footer-key-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #2d9e6b; +/* ── Hero (Welcome, Auth, API key screens) ── */ +.app-hero { + padding: 48px 0 36px; + text-align: center; } -.app-signout-btn { - font-size: 11px; +.app-hero-title { + font-family: 'Instrument Serif', serif; + font-size: clamp(36px, 9vw, 48px); + font-weight: 400; + line-height: 1.1; + letter-spacing: -0.02em; + margin-bottom: 10px; + color: var(--text); +} + +.app-hero-title em { + font-style: italic; color: var(--text-muted); - background: none; - border: none; - cursor: pointer; - font-family: 'Syne', sans-serif; - text-decoration: underline; - text-underline-offset: 2px; - transition: color 0.2s; - padding: 4px 0; } -.app-signout-btn:hover { - color: var(--text); -} \ No newline at end of file +.app-hero-sub { + font-size: 13px; + color: var(--text-muted); + letter-spacing: 0.01em; +} + +/* ── Mobile ── */ +@media (max-width: 768px) { + .chat-content { + padding: 0 16px; + } + + .chat-inputbar { + padding: 12px 16px 20px; + } +} diff --git a/src/App.jsx b/src/App.jsx index d16a0d5..8c05826 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,10 +6,11 @@ import AuthScreen from "./components/AuthScreen.jsx"; import ApiKeySetup from "./components/ApiKeySetup.jsx"; import MicButton from "./components/MicButton.jsx"; import OutputBox from "./components/OutputBox.jsx"; -import History from "./components/History.jsx"; import Header from "./components/Header.jsx"; +import Sidebar from "./components/Sidebar.jsx"; import ExportModal from "./components/ExportModal.jsx"; import SettingsModal from "./components/SettingsModal.jsx"; +import { HamburgerIcon } from "./components/Icons.jsx"; import "./App.css"; export default function App() { @@ -18,26 +19,29 @@ export default function App() { ); const [user, setUser] = useState(undefined); const [authLoading, setAuthLoading] = useState(true); - const [dark, setDark] = useState( - () => { - const saved = localStorage.getItem("sidekick_dark_mode"); - return saved === null ? true : saved === "1"; - } - ); + const [dark, setDark] = useState(() => { + const saved = localStorage.getItem("sidekick_dark_mode"); + return saved === null ? true : saved === "1"; + }); const [apiKey, setApiKey] = useState(""); const [output, setOutput] = useState(""); const [processing, setProcessing] = useState(false); - const [history, setHistory] = useState([]); + const [conversations, setConversations] = useState([]); + const [activeConversationId, setActiveConversationId] = useState(null); const [showExportModal, setShowExportModal] = useState(false); const [showSettingsModal, setShowSettingsModal] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => window.innerWidth < 768 + ); + // Dark mode useEffect(() => { document.body.classList.toggle("dark", dark); localStorage.setItem("sidekick_dark_mode", dark ? "1" : "0"); }, [dark]); + // Auth listener useEffect(() => { - // Allow Cypress to inject a mock user for E2E testing const mockUser = window.__CYPRESS_MOCK_USER__; if (mockUser) { setUser(mockUser); @@ -50,12 +54,9 @@ export default function App() { const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => { setUser(firebaseUser); setAuthLoading(false); - if (firebaseUser) { const savedKey = localStorage.getItem(`sidekick_apikey_${firebaseUser.uid}`); - if (savedKey) { - setApiKey(savedKey); - } + if (savedKey) setApiKey(savedKey); } else { setApiKey(""); } @@ -63,6 +64,30 @@ export default function App() { return () => unsubscribe(); }, []); + // Load conversations when user authenticates + useEffect(() => { + if (user && user.uid) { + const saved = localStorage.getItem(`sidekick_conversations_${user.uid}`); + if (saved) { + try { + setConversations(JSON.parse(saved)); + } catch { + setConversations([]); + } + } + } + }, [user]); + + // Persist conversations + useEffect(() => { + if (user && user.uid && conversations.length > 0) { + localStorage.setItem( + `sidekick_conversations_${user.uid}`, + JSON.stringify(conversations) + ); + } + }, [conversations, user]); + const handleWelcomeContinue = () => { localStorage.setItem("sidekick_seen_welcome", "1"); setShowWelcome(false); @@ -79,92 +104,174 @@ export default function App() { await signOut(auth); setApiKey(""); setOutput(""); - setHistory([]); + setConversations([]); + setActiveConversationId(null); }; const handleResult = (text) => { setOutput(text); - setHistory((prev) => [ - { - text, - time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), - }, - ...prev.slice(0, 4), - ]); + const title = + text.substring(0, 60).replace(/\n/g, " ").trim() || "Untitled"; + const now = new Date().toISOString(); + + if (activeConversationId) { + setConversations((prev) => + prev.map((c) => + c.id === activeConversationId + ? { ...c, text, title, updatedAt: now } + : c + ) + ); + } else { + const newId = crypto.randomUUID(); + setConversations((prev) => [ + { id: newId, title, text, createdAt: now, updatedAt: now }, + ...prev, + ]); + setActiveConversationId(newId); + } + }; + + const handleNewConversation = () => { + setActiveConversationId(null); + setOutput(""); + }; + + const handleSelectConversation = (id) => { + const convo = conversations.find((c) => c.id === id); + if (convo) { + setActiveConversationId(id); + setOutput(convo.text); + } + if (window.innerWidth < 768) { + setSidebarCollapsed(true); + } }; + const isMainApp = !authLoading && !showWelcome && !!user && !!apiKey; + const hero = (
Voice-to-text, formatted for you
+ Tap the mic to start recording +
+