From 3d6e1f2b7a92a1893fc658c5550344bb7d9c4f54 Mon Sep 17 00:00:00 2001 From: Hank Li Date: Fri, 21 Aug 2026 15:35:19 -0500 Subject: [PATCH] implement calls product surface with fixtures and ui tests --- .../__tests__/calls/CallsWorkspace.test.tsx | 121 ++ .../app/calls/_components/CallsWorkspace.tsx | 616 ++++++++ .../src/app/calls/_fixtures/callSnapshots.ts | 223 +++ apps/web/src/app/calls/calls.module.css | 1286 +++++++++++++++++ apps/web/src/app/calls/page.tsx | 6 + 5 files changed, 2252 insertions(+) create mode 100644 apps/web/__tests__/calls/CallsWorkspace.test.tsx create mode 100644 apps/web/src/app/calls/_components/CallsWorkspace.tsx create mode 100644 apps/web/src/app/calls/_fixtures/callSnapshots.ts create mode 100644 apps/web/src/app/calls/calls.module.css create mode 100644 apps/web/src/app/calls/page.tsx diff --git a/apps/web/__tests__/calls/CallsWorkspace.test.tsx b/apps/web/__tests__/calls/CallsWorkspace.test.tsx new file mode 100644 index 000000000..0f99dfb62 --- /dev/null +++ b/apps/web/__tests__/calls/CallsWorkspace.test.tsx @@ -0,0 +1,121 @@ +/** @jest-environment jsdom */ + +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; + +import { CallsWorkspace } from "~/app/calls/_components/CallsWorkspace"; +import { + enrichmentReadyCall, + failedCall, + northstarPricingReviewCall, + partialCall, + pausedCall, + redactedCall, +} from "~/app/calls/_fixtures/callSnapshots"; + +describe("CallsWorkspace", () => { + it("renders a completed call's title, note, and transcript segments", async () => { + const user = userEvent.setup(); + render(); + + const panel = screen.getByRole("main"); + expect( + within(panel).getByRole("heading", { name: /northstar pricing review/i }) + ).toBeInTheDocument(); + expect(within(panel).getByText(/hank drafting the enterprise tier/i)).toBeInTheDocument(); + + // completed call has no capture control in the panel + expect(within(panel).queryByRole("button", { name: /^(start|pause|resume)$/i })).toBeNull(); + expect(within(panel).queryByRole("button", { name: /retry capture/i })).toBeNull(); + + await user.click(within(panel).getByRole("button", { name: /transcript/i })); + const transcript = screen.getByLabelText("Transcript segments"); + expect( + within(transcript).getByText(/finalize the pricing tiers before friday/i) + ).toBeInTheDocument(); + }); + + it("shows Paused status with a Resume control for a paused capture", () => { + render(); + expect(screen.getByRole("status", { name: /capture status/i })).toHaveTextContent(/paused/i); + expect(screen.getByRole("button", { name: /resume/i })).toBeInTheDocument(); + }); + + it("shows Failed status with a Retry control for a failed capture", () => { + render(); + expect(screen.getByRole("status", { name: /capture status/i })).toHaveTextContent(/failed/i); + expect(screen.getByRole("button", { name: /retry capture/i })).toBeInTheDocument(); + }); + + it("marks a partial call and renders the gap in the transcript timeline", async () => { + const user = userEvent.setup(); + render(); + expect(screen.getByRole("status", { name: /capture status/i })).toHaveTextContent(/partial/i); + + await user.click(screen.getByRole("button", { name: /transcript/i })); + const transcript = screen.getByLabelText("Transcript segments"); + expect(within(transcript).getByText(/capture paused/i)).toBeInTheDocument(); + expect(within(transcript).getByText(/45s not transcribed/i)).toBeInTheDocument(); + }); + + it("redacts a private note for a non-owner", () => { + render(); + expect(screen.getByText(/private to the owner/i)).toBeInTheDocument(); + }); + + it("shows the AI-enhanced proposal when enrichment is ready", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("tab", { name: /ai enhanced/i })); + expect(screen.getByText(/finalize the pricing tiers by friday/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /review suggestion/i })).toBeInTheDocument(); + }); + + it("lists every call in the rail and switches the panel when one is selected", async () => { + const user = userEvent.setup(); + render(); + + const rail = screen.getByRole("complementary", { name: /calls library/i }); + expect(within(rail).getByRole("button", { name: /northstar pricing review/i })).toBeInTheDocument(); + expect(within(rail).getByRole("button", { name: /dropped investor call/i })).toBeInTheDocument(); + + const panel = screen.getByRole("main"); + expect(within(panel).getByRole("heading", { name: /northstar pricing review/i })).toBeInTheDocument(); + + await user.click(within(rail).getByRole("button", { name: /dropped investor call/i })); + expect(screen.getByRole("status", { name: /capture status/i })).toHaveTextContent(/failed/i); + expect(screen.getByRole("button", { name: /retry capture/i })).toBeInTheDocument(); + }); + + it("resets panel view state when switching to another call", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("tab", { name: /ai enhanced/i })); + expect(screen.getByText(/finalize the pricing tiers by friday/i)).toBeInTheDocument(); + + // switching calls should reset the panel back to My notes (not stay on AI) + const rail = screen.getByRole("complementary", { name: /calls library/i }); + await user.click(within(rail).getByRole("button", { name: /private 1:1/i })); + expect(screen.getByText(/private to the owner/i)).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /my notes/i })).toHaveAttribute( + "aria-selected", + "true" + ); + }); + + it("filters the rail by call title", async () => { + const user = userEvent.setup(); + render(); + + const rail = screen.getByRole("complementary", { name: /calls library/i }); + await user.type(within(rail).getByRole("textbox", { name: /search calls/i }), "northstar"); + + expect( + within(rail).getByRole("button", { name: /northstar pricing review/i }) + ).toBeInTheDocument(); + expect(within(rail).queryByRole("button", { name: /dropped investor call/i })).toBeNull(); + }); +}); diff --git a/apps/web/src/app/calls/_components/CallsWorkspace.tsx b/apps/web/src/app/calls/_components/CallsWorkspace.tsx new file mode 100644 index 000000000..016e76cdc --- /dev/null +++ b/apps/web/src/app/calls/_components/CallsWorkspace.tsx @@ -0,0 +1,616 @@ +"use client"; + +import { + Bookmark, + ChevronDown, + ChevronRight, + Clock3, + FileText, + Link2, + LockKeyhole, + Pause, + Play, + Plus, + Radio, + RefreshCcw, + Search, + ShieldCheck, + Sparkles, + Users, + X, +} from "lucide-react"; +import { useState } from "react"; + +import type { CallSnapshot, Gap, TranscriptSegment } from "@launchstack/features/call-notes"; + +import styles from "../calls.module.css"; + +function notWired(action: string) { + console.warn(`[calls] "${action}" is not wired yet`); +} + +function companyLabel(snapshot: CallSnapshot): string { + return `Company ${snapshot.companyId}`; +} + +function formatDateTime(iso: string): string { + return new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "UTC", + }).format(new Date(iso)); +} + +function formatClock(ms: number | null): string { + if (ms === null) return ""; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +type CaptureControl = "start" | "pause" | "resume" | "retry" | "connecting" | "none"; +type CaptureStatusKey = "live" | "connecting" | "paused" | "failed" | "completed"; +type CaptureView = { + badge: string; + control: CaptureControl; + statusKey: CaptureStatusKey; + partial: boolean; +}; +function deriveCapture(snapshot: CallSnapshot): CaptureView { + const { lifecycle, desiredMode, outcome } = snapshot.capture; + const partial = outcome === "partial"; + + if (lifecycle === "completed") + return { badge: "Completed", control: "none", statusKey: "completed", partial }; + if (lifecycle === "failed") + return { badge: "Failed", control: "retry", statusKey: "failed", partial }; + if (lifecycle === "finalizing") + return { badge: "Finalizing", control: "none", statusKey: "connecting", partial }; + + if (desiredMode === "paused") + return { badge: "Paused", control: "resume", statusKey: "paused", partial }; + + switch (lifecycle) { + case "connecting": + return { badge: "Connecting", control: "connecting", statusKey: "connecting", partial }; + case "interrupted": + return { badge: "Reconnecting", control: "connecting", statusKey: "connecting", partial }; + case "live": + return { badge: "Live", control: "pause", statusKey: "live", partial }; + default: + return { badge: "Ready", control: "start", statusKey: "connecting", partial }; + } +} + +const GAP_LABELS: Record = { + user_paused: "Capture paused", + capture_user_absent: "Capture user away", + transport_interruption: "Connection interrupted", + worker_unavailable: "Processing unavailable", + provider_unknown: "Zoom interruption", +}; +function gapDuration(gap: Gap): string { + if (!gap.endedAt) return "ongoing"; + const ms = new Date(gap.endedAt).getTime() - new Date(gap.startedAt).getTime(); + const seconds = Math.max(0, Math.round(ms / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`; +} + +export function CallsWorkspace({ calls }: { calls: CallSnapshot[] }) { + const [railOpen, setRailOpen] = useState(false); + const [selectedId, setSelectedId] = useState(calls[0]?.id ?? null); + + const selected = calls.find((call) => call.id === selectedId) ?? calls[0] ?? null; + + return ( +
+ { + setSelectedId(id); + setRailOpen(false); + }} + onClose={() => setRailOpen(false)} + /> + {railOpen ? ( +
+ ); +} + +function CallsRail({ + calls, + selectedId, + open, + onSelect, + onClose, +}: { + calls: CallSnapshot[]; + selectedId: string | null; + open: boolean; + onSelect: (id: string) => void; + onClose: () => void; +}) { + const [query, setQuery] = useState(""); + + const normalized = query.trim().toLowerCase(); + const matches = normalized + ? calls.filter((call) => call.title.toLowerCase().includes(normalized)) + : calls; + const live = matches.filter((call) => call.status === "active"); + const recent = matches.filter((call) => call.status !== "active"); + + return ( + + ); +} + +function RailItem({ + call, + selected, + onSelect, +}: { + call: CallSnapshot; + selected: boolean; + onSelect: (id: string) => void; +}) { + const capture = deriveCapture(call); + const isLive = call.status === "active"; + return ( + + ); +} + +function CallPanel({ snapshot, onMenu }: { snapshot: CallSnapshot; onMenu: () => void }) { + const capture = deriveCapture(snapshot); + const [noteView, setNoteView] = useState<"notes" | "enhanced">("notes"); + const enrichmentReady = snapshot.enrichment?.status === "ready"; + + return ( +
+
+ + + + + +
+ + + +
+
+ +
+
+ + + {formatDateTime(snapshot.createdAt)} + + + + {snapshot.transcript.length} segments + + + + Zoom + +
+
+
+ +
+
+
+
+
+ + +
+ {snapshot.note ? saveLabel(snapshot.note.saveState) : "Private"} +
+ + {noteView === "notes" ? ( + + ) : ( + + )} + +
+ {snapshot.note?.visibility === "company" ? ( + + + Shared with company + + ) : ( + + + Private note + + )} +
+
+ + +
+
+
+ ); +} + +function saveLabel(state: "saved" | "saving" | "failed"): string { + if (state === "saving") return "Saving…"; + if (state === "failed") return "Save failed"; + return "Saved"; +} + +function CaptureControls({ control }: { control: CaptureControl }) { + if (control === "pause") + return ( + + ); + if (control === "resume") + return ( + + ); + if (control === "connecting") + return ( + + + Connecting… + + ); + if (control === "retry") + return ( + + ); + if (control === "none") return null; + return ( + + ); +} + +function NoteBody({ snapshot }: { snapshot: CallSnapshot }) { + // note === null is a non-owner viewing a private note. + if (!snapshot.note) { + return ( +
+ + Private to the owner + The company transcript remains available. +
+ ); + } + return ( +
+ {snapshot.note.contentMarkdown || "Write notes here…"} +
+ ); +} + +function EnhancedBody({ snapshot }: { snapshot: CallSnapshot }) { + const proposal = snapshot.enrichment?.proposal; + if (!proposal) { + return ( +
+ + AI enhancement starts after capture + The transcript and your notes stay separate until the suggestion is ready. +
+ ); + } + return ( +
+
+ + + AI-enhanced draft + + +
+

{proposal.summary}

+
+ ); +} + +type TimelineEntry = + | { type: "segment"; segment: TranscriptSegment } + | { type: "gap"; gap: Gap }; + +function TranscriptSection({ snapshot }: { snapshot: CallSnapshot }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const normalized = query.trim().toLowerCase(); + const isSearching = normalized.length > 0; + const partial = snapshot.capture.outcome === "partial"; + + const matchedSegments = isSearching + ? snapshot.transcript.filter( + (segment) => + (segment.speakerName ?? "").toLowerCase().includes(normalized) || + segment.text.toLowerCase().includes(normalized) + ) + : snapshot.transcript; + + const timeline: TimelineEntry[] = []; + if (isSearching) { + for (const segment of matchedSegments) { + timeline.push({ type: "segment", segment }); + } + } else { + const sortedGaps = [...snapshot.gaps].sort((a, b) => a.startedAt.localeCompare(b.startedAt)); + let gapIndex = 0; + for (const segment of snapshot.transcript) { + while (gapIndex < sortedGaps.length) { + const gap = sortedGaps[gapIndex]; + if (!gap || gap.startedAt > segment.receivedAt) break; + timeline.push({ type: "gap", gap }); + gapIndex += 1; + } + timeline.push({ type: "segment", segment }); + } + for (; gapIndex < sortedGaps.length; gapIndex += 1) { + const gap = sortedGaps[gapIndex]; + if (gap) timeline.push({ type: "gap", gap }); + } + } + + return ( +
+
+ + {open ? ( + + ) : partial ? ( + Capture had gaps · Partial + ) : null} +
+ + {open ? ( +
+ {timeline.length ? ( + timeline.map((entry) => + entry.type === "segment" ? ( + item.segmentId === entry.segment.id + )} + /> + ) : ( +
+ + {GAP_LABELS[entry.gap.kind]} + {gapDuration(entry.gap)} not transcribed +
+ ) + ) + ) : ( +
+ {isSearching + ? `No transcript matches “${query.trim()}”.` + : "Transcript appears when Zoom connects."} +
+ )} +
+ ) : null} +
+ ); +} + +function SegmentRow({ + segment, + bookmark, +}: { + segment: TranscriptSegment; + bookmark: CallSnapshot["bookmarks"][number] | undefined; +}) { + return ( +
+
+ {segment.speakerName ?? "Unknown speaker"} + {formatClock(segment.providerStartMs)} +
+

{segment.text}

+ + {bookmark?.comment ? ( + {bookmark.comment} + ) : null} +
+ ); +} diff --git a/apps/web/src/app/calls/_fixtures/callSnapshots.ts b/apps/web/src/app/calls/_fixtures/callSnapshots.ts new file mode 100644 index 000000000..2cfac01b8 --- /dev/null +++ b/apps/web/src/app/calls/_fixtures/callSnapshots.ts @@ -0,0 +1,223 @@ +import { CallSnapshotSchema, type CallSnapshot } from "@launchstack/features/call-notes"; + +// base input that the variants below extend. +const baseCallInput = { + schemaVersion: "call-notes/v1", + id: "call-northstar-pricing", + companyId: "42", + provider: "zoom", + occurrenceKey: "zoom-northstar-2026-08-21", + title: "Northstar pricing review", + status: "completed", + capture: { + id: "capture-northstar-1", + desiredMode: "running", + lifecycle: "completed", + outcome: "complete", + activeAttemptId: null, + attemptCount: 1, + }, + transcript: [ + { + id: "segment-1", + attemptId: "attempt-1", + participantId: "participant-deodat", + speakerName: "Deodat", + providerStartMs: 0, + providerEndMs: 4200, + receivedAt: "2026-08-21T14:00:04.000Z", + receiveOrder: 0, + text: "Let's aim to finalize the pricing tiers before Friday.", + language: "en", + }, + { + id: "segment-2", + attemptId: "attempt-1", + participantId: "participant-hank", + speakerName: "Hank", + providerStartMs: 4200, + providerEndMs: 9000, + receivedAt: "2026-08-21T14:00:09.000Z", + receiveOrder: 1, + text: "Agreed. I'll draft the enterprise tier and share it tomorrow.", + language: "en", + }, + ], + gaps: [], + bookmarks: [ + { + id: "bookmark-1", + segmentId: "segment-1", + comment: "Pricing deadline", + createdAt: "2026-08-21T14:05:00.000Z", + }, + ], + note: { + documentNoteId: 101, + ownerUserId: "user-hank", + visibility: "company", + knowledgeIncluded: false, + revision: 1, + title: "Northstar pricing review", + contentMarkdown: "- Finalize pricing tiers by Friday\n- Hank drafting the enterprise tier", + contentRich: {}, + saveState: "saved", + }, + enrichment: null, + createdAt: "2026-08-21T14:00:00.000Z", + updatedAt: "2026-08-21T14:10:00.000Z", +} as const; + +// completed call with a note and transcript +export const northstarPricingReviewCall: CallSnapshot = CallSnapshotSchema.parse(baseCallInput); + +// live call the user has paused +export const pausedCall: CallSnapshot = CallSnapshotSchema.parse({ + ...baseCallInput, + id: "call-paused", + occurrenceKey: "zoom-paused-1", + title: "Live roadmap sync", + status: "active", + capture: { + id: "capture-paused-1", + desiredMode: "paused", + lifecycle: "live", + outcome: null, + activeAttemptId: "attempt-paused-1", + attemptCount: 1, + }, + note: { ...baseCallInput.note, saveState: "saving" }, + enrichment: null, +}); + +// a capture that failed to connect +export const failedCall: CallSnapshot = CallSnapshotSchema.parse({ + ...baseCallInput, + id: "call-failed", + occurrenceKey: "zoom-failed-1", + title: "Dropped investor call", + status: "failed", + capture: { + id: "capture-failed-1", + desiredMode: "running", + lifecycle: "failed", + outcome: "failed", + activeAttemptId: null, + attemptCount: 2, + }, + transcript: [], + gaps: [], + bookmarks: [], + note: { ...baseCallInput.note, title: "Dropped investor call", contentMarkdown: "" }, + enrichment: null, +}); + +// a completed but partial call with a capture gap between the two segments +export const partialCall: CallSnapshot = CallSnapshotSchema.parse({ + ...baseCallInput, + id: "call-partial", + occurrenceKey: "zoom-partial-1", + title: "Partial support review", + capture: { + id: "capture-partial-1", + desiredMode: "running", + lifecycle: "completed", + outcome: "partial", + activeAttemptId: null, + attemptCount: 1, + }, + transcript: [ + { + id: "segment-1", + attemptId: "attempt-1", + participantId: "participant-deodat", + speakerName: "Deodat", + providerStartMs: 0, + providerEndMs: 4200, + receivedAt: "2026-08-21T14:00:04.000Z", + receiveOrder: 0, + text: "Let's aim to finalize the pricing tiers before Friday.", + language: "en", + }, + { + id: "segment-2", + attemptId: "attempt-1", + participantId: "participant-hank", + speakerName: "Hank", + providerStartMs: 51000, + providerEndMs: 55000, + receivedAt: "2026-08-21T14:00:52.000Z", + receiveOrder: 1, + text: "Sorry, I'm back — I'll draft the enterprise tier and share it tomorrow.", + language: "en", + }, + ], + gaps: [ + { + id: "gap-1", + attemptId: "attempt-1", + kind: "user_paused", + startedAt: "2026-08-21T14:00:06.000Z", + endedAt: "2026-08-21T14:00:51.000Z", + }, + ], +}); + +// a private note viewed by a non-owner +export const redactedCall: CallSnapshot = CallSnapshotSchema.parse({ + ...baseCallInput, + id: "call-redacted", + occurrenceKey: "zoom-redacted-1", + title: "Private 1:1", + note: null, +}); + +// a call whose AI-enhanced proposal is ready to review +export const enrichmentReadyCall: CallSnapshot = CallSnapshotSchema.parse({ + ...baseCallInput, + id: "call-enriched", + occurrenceKey: "zoom-enriched-1", + title: "Enriched strategy call", + enrichment: { + id: "enrichment-1", + status: "ready", + baseNoteRevision: 1, + transcriptFingerprint: "a".repeat(64), + proposal: { + schemaVersion: "call-notes-enrichment/v1", + chronologicalSections: [ + { heading: "Pricing", markdown: "Discussed the tier structure.", ownerContextLabels: [] }, + ], + summary: "The team agreed to finalize the pricing tiers by Friday; Hank will draft the enterprise tier.", + actionItems: [{ text: "Draft the enterprise tier", ownerName: "Hank", dueDate: "2026-08-28" }], + bookmarkPassages: [ + { + markdown: "Pricing deadline is Friday.", + citations: [ + { + bookmarkId: "bookmark-1", + segmentId: "segment-1", + speakerName: "Deodat", + providerStartMs: 0, + }, + ], + }, + ], + conflicts: [], + contentMarkdown: "The team agreed to finalize the pricing tiers by Friday.", + contentRich: {}, + }, + modelMetadata: { model: "claude-sonnet", promptVersion: "calls-v1" }, + createdAt: "2026-08-21T14:15:00.000Z", + resolvedAt: null, + }, +}); + +export const sampleCalls: CallSnapshot[] = [ + pausedCall, + northstarPricingReviewCall, + partialCall, + enrichmentReadyCall, + failedCall, + redactedCall, +]; diff --git a/apps/web/src/app/calls/calls.module.css b/apps/web/src/app/calls/calls.module.css new file mode 100644 index 000000000..5c9237fe2 --- /dev/null +++ b/apps/web/src/app/calls/calls.module.css @@ -0,0 +1,1286 @@ +.root { + --call-rail: #f3f2ef; + --call-paper: #fff; + --call-canvas: #e9e8e4; + --call-ink: #252421; + --call-muted: #77746d; + --call-faint: #aaa69d; + --call-line: #e8e6e0; + --call-line-strong: #d9d6ce; + --call-accent: #6555d8; + --call-accent-soft: #f0edff; + --call-danger: #b5443c; + --call-danger-soft: #fff2f0; + --call-success: #2d7a54; + display: flex; + min-width: 0; + min-height: 100dvh; + overflow: hidden; + color: var(--call-ink); + background: var(--call-canvas); + font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif; + font-size: 14px; + line-height: 1.45; +} + +.root :global(*) { + box-sizing: border-box; +} + +.root button, +.root input, +.root textarea { + color: inherit; + font: inherit; +} + +.root button { + border: 0; +} + +.rail { + position: relative; + z-index: 20; + display: flex; + flex: 0 0 272px; + flex-direction: column; + width: 272px; + min-width: 0; + height: 100dvh; + overflow: hidden; + background: var(--call-rail); + border-right: 1px solid var(--call-line-strong); +} + +.railBrand, +.railTitle { + display: flex; + align-items: center; +} + +.railBrand { + gap: 9px; + height: 56px; + padding: 0 18px; + font-size: 13px; +} + +.railBrand .iconButton { + display: none; + margin-left: auto; +} + +.brandMark { + display: grid; + place-items: center; + width: 22px; + height: 22px; + color: #fff; + background: var(--call-ink); + border-radius: 6px; + font-size: 11px; + font-weight: 750; +} + +.railTitle { + justify-content: space-between; + padding: 18px 18px 12px; +} + +.railTitle h1 { + margin: 0; + font-size: 22px; + font-weight: 680; + letter-spacing: -0.035em; +} + +.iconButton, +.controlButton, +.primaryButton, +.textButton, +.bookmarkButton, +.citation { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border-radius: 7px; + cursor: pointer; + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease; +} + +.iconButton { + width: 30px; + height: 30px; + padding: 0; + background: transparent; +} + +.iconButton:hover, +.textButton:hover { + background: rgb(0 0 0 / 0.055); +} + +.search { + display: flex; + align-items: center; + gap: 8px; + margin: 0 14px 16px; + padding: 7px 10px; + color: var(--call-muted); + background: rgb(255 255 255 / 0.52); + border: 1px solid transparent; + border-radius: 7px; +} + +.search:focus-within { + background: #fff; + border-color: var(--call-line-strong); +} + +.search input { + width: 100%; + min-width: 0; + padding: 0; + background: transparent; + border: 0; + outline: 0; +} + +.search input::placeholder { + color: var(--call-faint); +} + +.railList { + flex: 1; + min-height: 0; + padding: 0 10px; + overflow: auto; +} + +.railList section + section { + margin-top: 24px; +} + +.railLabel { + display: block; + padding: 0 8px 7px; + color: var(--call-faint); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.callItem { + display: grid; + width: 100%; + padding: 10px 10px 11px; + text-align: left; + background: transparent; + border-radius: 8px; + cursor: pointer; +} + +.callItem:hover { + background: rgb(255 255 255 / 0.55); +} + +.callItemActive { + background: #fff; + box-shadow: 0 1px 2px rgb(37 36 33 / 0.06); +} + +.callItemActive:hover { + background: #fff; +} + +.callItem strong { + overflow: hidden; + margin: 2px 0 3px; + font-size: 13px; + font-weight: 620; + text-overflow: ellipsis; + white-space: nowrap; +} + +.callItem span { + overflow: hidden; + color: var(--call-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.liveMeta { + display: flex; + align-items: center; + gap: 5px; + color: var(--call-success) !important; + font-weight: 650; +} + +.liveDot, +.statusDot { + display: inline-block; + width: 6px; + height: 6px; + background: currentColor; + border-radius: 999px; +} + +.liveDot { + box-shadow: 0 0 0 3px rgb(45 122 84 / 0.12); +} + +.railFooter { + display: flex; + align-items: center; + gap: 8px; + margin: 10px; + padding: 10px; + color: var(--call-muted); + text-align: left; + background: transparent; + border-top: 1px solid var(--call-line); + cursor: pointer; +} + +.railFooter:hover { + color: var(--call-ink); +} + +.railScrim { + display: none; +} + +.panel { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + height: 100dvh; + overflow: hidden; + background: var(--call-paper); +} + +.panelHeader { + display: grid; + grid-template-columns: auto 1fr auto auto; + align-items: center; + gap: 8px; + min-height: 72px; + padding: 16px 28px; + border-bottom: 1px solid var(--call-line); +} + +.panelHeader > .iconButton:first-child { + display: none; +} + +.headerSpacer { + min-width: 0; +} + +.status { + display: inline-flex; + align-items: center; + gap: 7px; + width: max-content; + color: var(--call-muted); + font-size: 11px; + font-weight: 650; +} + +.status_live, +.status_connecting { + color: var(--call-success); +} + +.status_connecting .statusDot { + animation: pulse 1.25s ease-in-out infinite; +} + +.status_paused { + color: #9b6b12; +} + +.status_failed { + color: var(--call-danger); +} + +.status_completed { + color: var(--call-muted); +} + +.partialLabel { + padding-left: 7px; + border-left: 1px solid currentColor; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.controlButton, +.primaryButton, +.textButton { + min-height: 30px; + padding: 6px 10px; + font-size: 12px; + font-weight: 620; +} + +.controlButton { + background: #fff; + border: 1px solid var(--call-line-strong) !important; +} + +.controlButton:hover { + background: #f8f7f4; +} + +.primaryButton { + color: #fff !important; + background: var(--call-ink); +} + +.primaryButton:hover { + background: #3a3935; +} + +.textButton { + color: var(--call-muted); + background: transparent; +} + +.connecting, +.readOnly { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--call-muted); + font-size: 12px; +} + +.titleBlock { + grid-column: 1 / -1; + min-width: 0; + padding-top: 13px; +} + +.titleLine { + display: flex; + align-items: center; + min-width: 0; +} + +.titleButton { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 0; + background: transparent; + cursor: pointer; +} + +.titleButton svg { + flex: none; + color: var(--call-faint); + opacity: 0; + transition: opacity 120ms ease; +} + +.titleButton:hover svg, +.titleButton:focus-visible svg { + opacity: 1; +} + +.titleButton h1, +.titleInput { + min-width: 0; + margin: 0; + font-size: clamp(22px, 3vw, 28px); + font-weight: 660; + letter-spacing: -0.04em; + line-height: 1.15; +} + +.titleInput { + width: min(640px, 100%); + padding: 0 0 3px; + background: transparent; + border: 0; + border-bottom: 1px solid var(--call-ink); + outline: 0; +} + +.callMeta { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-top: 9px; + color: var(--call-muted); + font-size: 11px; +} + +.callMeta span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.panelScroll { + flex: 1; + min-height: 0; + overflow: auto; +} + +.panelContent { + width: min(860px, calc(100% - 64px)); + margin: 0 auto; + padding: 28px 0 72px; +} + +.notice, +.failure { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 11px; + margin-bottom: 18px; + padding: 10px 12px; + border: 1px solid var(--call-line); + border-radius: 9px; +} + +.notice { + background: #fbfaf7; +} + +.noticeIcon { + display: grid; + place-items: center; + width: 28px; + height: 28px; + color: var(--call-accent); + background: var(--call-accent-soft); + border-radius: 7px; +} + +.notice div, +.failure div { + display: grid; + min-width: 0; +} + +.notice strong, +.failure strong { + font-size: 12px; + font-weight: 660; +} + +.notice div span, +.failure div span { + overflow: hidden; + color: var(--call-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.failure { + color: var(--call-danger); + background: var(--call-danger-soft); + border-color: #f0d6d2; +} + +.note { + min-height: 330px; + padding: 24px 0 0; +} + +.noteHeading, +.noteFooter { + display: flex; + align-items: center; + justify-content: space-between; +} + +.noteHeading { + gap: 18px; + min-height: 47px; + padding-bottom: 12px; + border-bottom: 1px solid var(--call-line); +} + +.noteHeading small, +.noteFooter { + color: var(--call-faint); + font-size: 11px; +} + +.noteHeading small { + flex: none; +} + +.noteSwitcher { + display: inline-flex; + gap: 2px; + padding: 3px; + background: #f1f0ed; + border-radius: 9px; +} + +.noteTab { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + padding: 6px 10px; + color: var(--call-muted); + background: transparent; + border-radius: 7px; + cursor: pointer; + font-size: 11px; + font-weight: 620; +} + +.noteTab:hover:not(:disabled) { + color: var(--call-ink); +} + +.noteTabActive { + color: var(--call-ink); + background: #fff; + box-shadow: 0 1px 2px rgb(37 36 33 / 0.08); +} + +.noteTab:disabled { + cursor: default; + opacity: 0.55; +} + +.readyDot { + width: 5px; + height: 5px; + background: var(--call-accent); + border-radius: 999px; + box-shadow: 0 0 0 3px rgb(101 85 216 / 0.1); +} + +.enhancedNote { + min-height: 260px; + padding: 25px 2px 36px; +} + +.enhancedHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 22px; +} + +.enhancedHeader > span { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--call-accent); + font-size: 12px; +} + +.enhancedNote p { + max-width: 700px; + margin: 0; + color: #3f3d38; + font-family: var(--font-serif), Georgia, serif; + font-size: 18px; + line-height: 1.68; +} + +.enhancedEmpty { + display: grid; + justify-items: start; + gap: 7px; + padding-top: 5px; + color: var(--call-muted); +} + +.enhancedEmpty svg { + color: var(--call-accent); +} + +.enhancedEmpty span { + max-width: 430px; + font-size: 12px; +} + +.noteEditor { + min-height: 260px; + padding: 25px 2px 36px; + outline: 0; + font-family: var(--font-serif), Georgia, serif; + font-size: 18px; + line-height: 1.68; + white-space: pre-wrap; +} + +.noteEditor:empty::before { + color: #aaa79f; + content: attr(data-placeholder); + pointer-events: none; +} + +.noteEditor:focus { + caret-color: var(--call-accent); +} + +.privateNote { + display: grid; + justify-items: start; + gap: 6px; + min-height: 260px; + padding: 30px 2px; + color: var(--call-muted); +} + +.privateNote span { + font-size: 12px; +} + +.noteFooter { + gap: 14px; + min-height: 42px; + border-top: 1px solid var(--call-line); +} + +.noteFooter > span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.visibility { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 7px; + color: var(--call-muted); + border-radius: 6px; + cursor: pointer; +} + +.visibility:hover { + color: var(--call-ink); + background: #f7f6f3; +} + +.visibility input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.transcript { + margin-top: 16px; + overflow: hidden; + background: #fbfaf8; + border: 1px solid var(--call-line-strong); + border-radius: 12px; +} + +.transcriptHeader { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-height: 68px; + padding: 8px 12px 8px 5px; +} + +.transcriptToggle { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 10px; + min-width: 0; + padding: 9px; + text-align: left; + background: transparent; + border-radius: 8px; + cursor: pointer; +} + +.transcriptToggle:hover { + background: rgb(37 36 33 / 0.035); +} + +.transcriptToggle:hover .toggleIcon { + color: var(--call-ink); +} + +.toggleIcon { + display: grid; + place-items: center; + color: var(--call-faint); +} + +.transcriptToggle > span:nth-child(2) { + display: grid; + gap: 2px; + min-width: 0; +} + +.transcriptToggle strong { + font-size: 12px; + font-weight: 660; +} + +.transcriptToggle small { + display: flex; + align-items: center; + gap: 5px; + overflow: hidden; + color: var(--call-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.transcriptSearch { + display: flex; + align-items: center; + gap: 7px; + width: min(240px, 32vw); + padding: 7px 9px; + color: var(--call-muted); + background: #fff; + border: 1px solid var(--call-line-strong); + border-radius: 8px; +} + +.transcriptSearch:focus-within { + border-color: var(--call-accent); + box-shadow: 0 0 0 3px rgb(101 85 216 / 0.08); +} + +.transcriptSearch input { + width: 100%; + min-width: 0; + padding: 0; + background: transparent; + border: 0; + outline: 0; + font-size: 11px; +} + +.transcriptSearch button { + display: grid; + flex: none; + place-items: center; + width: 20px; + height: 20px; + padding: 0; + color: var(--call-muted); + background: transparent; + border-radius: 5px; + cursor: pointer; +} + +.transcriptSearch button:hover { + background: #f1f0ed; +} + +.gapSummary { + padding-right: 6px; + color: #9b6b12; + font-size: 11px; +} + +.transcriptBody { + max-height: 420px; + padding: 8px 12px 12px; + overflow: auto; + background: #fff; + border-top: 1px solid var(--call-line); +} + +.segment { + position: relative; + margin-top: 8px; + padding: 14px 44px 14px 14px; + background: #f6f5f2; + border: 1px solid transparent; + border-radius: 9px; + transition: + border-color 120ms ease, + background 120ms ease; +} + +.segment:hover, +.segment:focus-within { + background: #f9f8f6; + border-color: var(--call-line-strong); +} + +.segmentMeta { + display: flex; + align-items: baseline; + gap: 8px; +} + +.segmentMeta strong { + font-size: 12px; + font-weight: 660; +} + +.segmentMeta span { + color: var(--call-faint); + font-family: var(--font-mono), monospace; + font-size: 10px; +} + +.segment p { + margin: 7px 0 0; + color: #46443f; + font-size: 13px; + line-height: 1.58; +} + +.bookmarkButton { + position: absolute; + top: 10px; + right: 10px; + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + color: var(--call-muted); + background: #fff; + border: 1px solid var(--call-line); + border-radius: 7px; + cursor: pointer; + opacity: 0; + transform: translateY(2px); + transition: + opacity 120ms ease, + transform 120ms ease, + color 120ms ease; +} + +.segment:hover .bookmarkButton, +.segment:focus-within .bookmarkButton, +.bookmarkSaved { + opacity: 1; + transform: translateY(0); +} + +.bookmarkButton:hover, +.bookmarkSaved { + color: var(--call-accent); +} + +.bookmarkComment { + display: block; + margin-top: 11px; + padding-top: 9px; + color: var(--call-accent); + border-top: 1px solid var(--call-line); + font-size: 10px; +} + +.citation { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 7px; + color: var(--call-muted); + background: transparent; + border-radius: 6px; + cursor: pointer; + font-size: 10px; +} + +.citation:hover { + color: var(--call-accent); + background: var(--call-accent-soft); +} + +.gap { + display: flex; + align-items: center; + gap: 7px; + margin: 8px 2px 0; + padding: 8px 10px; + color: #8a651e; + background: #fff9eb; + border-radius: 7px; + font-size: 10px; +} + +.gap span { + color: #9a875e; +} + +.transcriptEmpty { + padding: 28px 12px; + color: var(--call-muted); + text-align: center; + font-size: 12px; +} + +.emptyPanel { + display: grid; + flex: 1; + place-content: center; + justify-items: center; + gap: 7px; + color: var(--call-muted); + text-align: center; +} + +.emptyPanel h2, +.emptyPanel p { + margin: 0; +} + +.emptyPanel h2 { + color: var(--call-ink); + font-size: 17px; +} + +.overlay, +.overlayTop { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + place-items: center; + padding: 20px; + background: rgb(36 34 31 / 0.36); + backdrop-filter: blur(2px); +} + +.overlayTop { + z-index: 80; + background: rgb(36 34 31 / 0.48); +} + +.dialog { + width: min(460px, 100%); + max-height: min(760px, calc(100dvh - 40px)); + padding: 22px; + overflow: auto; + background: #fff; + border: 1px solid var(--call-line-strong); + border-radius: 12px; + box-shadow: 0 22px 70px rgb(28 26 22 / 0.18); +} + +.reviewDialog { + width: min(880px, 100%); +} + +.dialogHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; +} + +.dialogHeader > div { + display: grid; + gap: 2px; +} + +.dialogHeader span, +.reviewLabel { + color: var(--call-muted); + font-size: 10px; + font-weight: 680; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.dialogHeader h2 { + margin: 0; + font-size: 19px; + letter-spacing: -0.025em; +} + +.dialog form { + display: grid; + gap: 10px; +} + +.field { + display: grid; + gap: 7px; + color: var(--call-muted); + font-size: 11px; +} + +.field em { + color: var(--call-faint); + font-style: normal; +} + +.field input, +.field textarea { + width: 100%; + padding: 10px 11px; + background: #fff; + border: 1px solid var(--call-line-strong); + border-radius: 7px; + outline: 0; +} + +.field textarea { + min-height: 90px; + resize: vertical; +} + +.field input:focus, +.field textarea:focus { + border-color: var(--call-accent); + box-shadow: 0 0 0 3px rgb(101 85 216 / 0.1); +} + +.fieldError { + margin: 0; + color: var(--call-danger); + font-size: 11px; +} + +.dialog blockquote { + margin: 0 0 18px; + padding: 13px; + color: #4b4842; + background: #f8f7f4; + border-radius: 8px; + font-size: 12px; + line-height: 1.55; +} + +.dialog blockquote p { + margin: 7px 0 0; +} + +.dialogActions { + display: flex; + justify-content: flex-end; + gap: 7px; + margin-top: 18px; +} + +.reviewGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + overflow: hidden; + background: var(--call-line); + border: 1px solid var(--call-line); + border-radius: 9px; +} + +.reviewGrid section { + min-width: 0; + padding: 17px; + background: #fff; +} + +.reviewLabel { + display: flex; + align-items: center; + gap: 5px; +} + +.reviewGrid p, +.reviewGrid textarea { + width: 100%; + min-height: 180px; + margin: 13px 0 0; + color: #44423d; + font-family: var(--font-serif), Georgia, serif; + font-size: 15px; + line-height: 1.58; +} + +.reviewGrid textarea { + padding: 0; + resize: none; + background: transparent; + border: 0; + outline: 0; +} + +.reviewGrid .citation { + margin-top: 12px; +} + +.toast { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 100; + display: flex; + align-items: center; + gap: 7px; + padding: 9px 12px; + color: #fff; + background: var(--call-ink); + border-radius: 8px; + box-shadow: 0 8px 26px rgb(20 19 17 / 0.18); + font-size: 11px; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } +} + +@media (max-width: 760px) { + .rail { + position: fixed; + inset: 0 auto 0 0; + width: min(312px, 88vw); + flex-basis: auto; + transform: translateX(-102%); + transition: transform 180ms ease; + } + + .railOpen { + transform: translateX(0); + } + + .railBrand .iconButton, + .panelHeader > .iconButton:first-child { + display: inline-flex; + } + + .railScrim { + position: fixed; + inset: 0; + z-index: 15; + display: block; + background: rgb(28 27 24 / 0.28); + } + + .panelHeader { + grid-template-columns: auto auto 1fr auto auto; + min-height: 64px; + padding: 12px 16px; + } + + .titleBlock { + padding-top: 9px; + } + + .panelContent { + width: min(100% - 34px, 860px); + padding-top: 18px; + } + + .notice, + .failure { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .notice .textButton, + .failure .textButton { + display: none; + } + + .reviewGrid { + grid-template-columns: 1fr; + } + + .bookmarkButton { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 480px) { + .panelHeader { + grid-template-columns: auto auto 1fr auto; + } + + .panelHeader > .iconButton:last-of-type:not(:first-child) { + display: none; + } + + .callMeta { + gap: 9px; + } + + .notice, + .failure { + align-items: start; + grid-template-columns: auto minmax(0, 1fr); + } + + .notice .primaryButton, + .failure .controlButton { + grid-column: 2; + justify-self: start; + } + + .note { + min-height: 300px; + } + + .noteEditor, + .privateNote, + .enhancedNote { + min-height: 230px; + } + + .transcriptHeader { + grid-template-columns: 1fr; + } + + .transcriptSearch { + width: 100%; + } + + .gapSummary { + padding: 0 9px 8px; + } + + .overlay, + .overlayTop { + align-items: end; + padding: 0; + } + + .dialog, + .reviewDialog { + width: 100%; + max-height: 90dvh; + border-radius: 14px 14px 0 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .root *, + .root *::before, + .root *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/apps/web/src/app/calls/page.tsx b/apps/web/src/app/calls/page.tsx new file mode 100644 index 000000000..056d1f630 --- /dev/null +++ b/apps/web/src/app/calls/page.tsx @@ -0,0 +1,6 @@ +import { CallsWorkspace } from "./_components/CallsWorkspace"; +import { sampleCalls } from "./_fixtures/callSnapshots"; + +export default function CallsPage() { + return ; +}