)}
{/* Primary CTA: the one primary on Overview. Hidden until the run is
finished, since a live scan would only email a partial report. */}
- {finished &&
+
{sections.map((s) => (
))}
) : reportMarkdown ? (
-
+
) : (
@@ -701,22 +698,6 @@ function OverviewTab({
)
)}
- {/* Near Recommendations: act on the fixes. */}
-
- {RECOMMENDATION_CTAS.map((item) => (
-
- ))}
-
-
- {/* Continuous coverage for your org (restyled upsell tiles). */}
-
-
Continuous coverage for your org
-
- {COVERAGE_CTAS.map((item) => (
-
- ))}
-
-
);
}
@@ -746,8 +727,7 @@ function TabButton({
function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
const { agents, events } = run.transcript;
const graphAgents = useMemo(() => buildGraphAgents(agents, events), [agents, events]);
- // Clicking a graph node opens the agent's transcript in a modal (matching the
- // cloud app); no node selected means no modal.
+ // Clicking a graph node opens the agent's transcript in a modal; no node selected means no modal.
const [selectedId, setSelectedId] = useState
(null);
const selectedAgent = selectedId ? (agents.find((a) => a.id === selectedId) ?? null) : null;
@@ -758,7 +738,7 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
-
+
Agent graph
{agents.length} agent{agents.length === 1 ? "" : "s"}
@@ -784,12 +764,12 @@ function AgentsTab({ run, canSteer }: { run: LoadedRun; canSteer: boolean }) {
{/* Re-run always routes to Strix Cloud. */}
-
Run this scan with more depth
-
Re-run this scan on managed infra in the cloud.
+
Run this pentest with more depth
+
Re-run this pentest on managed infra in the cloud.
- {selectedAgent && (
-
setSelectedId(null)}
- />
- )}
+ setSelectedId(null)}
+ />
);
}
diff --git a/strix/viewer/frontend/src/components/EmailReportView.tsx b/strix/viewer/frontend/src/components/EmailReportView.tsx
index 7f84cddf..a44da4ce 100644
--- a/strix/viewer/frontend/src/components/EmailReportView.tsx
+++ b/strix/viewer/frontend/src/components/EmailReportView.tsx
@@ -180,7 +180,7 @@ export default function EmailReportView({
const confirmationEmail = sentTo || auth?.email || email.trim();
return (
-
+
onExit(verifyOnly ? "history" : "overview")}
className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
@@ -192,7 +192,7 @@ export default function EmailReportView({
- {verifyOnly ? "Verify your email" : "Email report"}
+ {verifyOnly ? "Verify your email" : "Export report to PDF"}
@@ -225,16 +225,13 @@ export default function EmailReportView({
- Viewing stays local and nothing is uploaded. Emailing is an explicit
- opt-in: we send an encrypted PDF .
+ We email an encrypted PDF . Nothing else leaves your machine.
- The report is encrypted with a password that only you hold. Strix
- cannot read it and never stores it. We collect only your email so we
- can send it.
+ Only you hold the password; Strix can't read it.
@@ -242,7 +239,7 @@ export default function EmailReportView({
onClick={startFlow}
className="w-full cursor-pointer rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
- {verified ? "Email me the encrypted PDF" : "Continue with your email"}
+ Export report
{verified && auth?.email && (
Sending to {auth.email}
@@ -269,7 +266,6 @@ export default function EmailReportView({
className="w-full rounded-lg bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-[#444]"
style={{ border: "1px solid #2a2a2a" }}
/>
-
Use your work email.
-
-
-
-
-
-
-
-
{feature.headline}
-
{feature.description}
-
-
-
- Included in Strix Pro
-
-
- {INCLUDED.map((item) => {
- const BulletIcon = item.icon;
- return (
-
-
- {item.text}
-
- );
- })}
-
-
-
-
-
-
- );
-}
diff --git a/strix/viewer/frontend/src/components/FeedbackView.tsx b/strix/viewer/frontend/src/components/FeedbackView.tsx
new file mode 100644
index 00000000..f9d6985f
--- /dev/null
+++ b/strix/viewer/frontend/src/components/FeedbackView.tsx
@@ -0,0 +1,134 @@
+import { useState } from "react";
+import { ArrowLeft, AlertCircle, CheckCircle2 } from "lucide-react";
+import { IoChatbubblesOutline } from "react-icons/io5";
+import { submitFeedback } from "@/data/serverSource";
+import type { View } from "@/App";
+
+const MAX_MESSAGE = 5000;
+
+const ERROR_COPY: Record = {
+ invalid_email: "That email doesn't look right.",
+ invalid_message: "Please write a little more.",
+ unavailable: "Couldn't send that just now. Try again.",
+};
+
+/**
+ * Feedback & support form. Collects a message plus a work email (no
+ * verification — the email is taken as-is) and relays it to Strix via the local
+ * server. Mirrors EmailReportView's centered-card styling and palette.
+ */
+export default function FeedbackView({
+ defaultEmail,
+ onExit,
+}: {
+ defaultEmail: string | null;
+ onExit: (dest: View) => void;
+}) {
+ const [message, setMessage] = useState("");
+ const [email, setEmail] = useState(defaultEmail ?? "");
+ const [step, setStep] = useState<"form" | "sending" | "sent">("form");
+ const [error, setError] = useState(null);
+
+ const canSend = message.trim().length > 0 && email.trim().length > 0 && step !== "sending";
+
+ const send = async () => {
+ if (!canSend) return;
+ setStep("sending");
+ setError(null);
+ const result = await submitFeedback(message.trim(), email.trim());
+ if (result.ok) {
+ setStep("sent");
+ return;
+ }
+ setStep("form");
+ setError(ERROR_COPY[result.error] ?? ERROR_COPY.unavailable);
+ };
+
+ return (
+
+
onExit("overview")}
+ className="cursor-pointer inline-flex items-center gap-1.5 text-sm text-[#888] transition-colors hover:text-white"
+ >
+
+ Back to results
+
+
+
+
+
Feedback & support
+
+
+
+ {step === "sent" ? (
+
+
+
+
Thanks, we got it.
+
+ We read every message. If it needs a reply, we'll reach out to the email you gave.
+
+
{
+ setMessage("");
+ setStep("form");
+ }}
+ className="mt-4 cursor-pointer text-xs text-[#888] transition-colors hover:text-white"
+ >
+ Send more feedback
+
+
+
+ ) : (
+ <>
+
+ Bugs, feature requests, or anything else. Tell us what's on your mind.
+
+
+ {error && (
+
+ )}
+
+
+ Your feedback
+
+
+
+ Your work email
+ setEmail(e.target.value)}
+ placeholder="you@company.com"
+ className="w-full rounded-lg border border-[#2a2a2a] bg-black px-3 py-2.5 text-sm text-white outline-none transition-colors focus:border-white/50 focus:ring-2 focus:ring-white/10"
+ />
+
+
+
void send()}
+ disabled={!canSend}
+ className="mt-4 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-white px-4 py-2.5 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:opacity-60"
+ >
+ {step === "sending" ? "Sending…" : "Send feedback"}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/strix/viewer/frontend/src/components/PastRunsView.tsx b/strix/viewer/frontend/src/components/PastRunsView.tsx
index ac043097..adfeb4dd 100644
--- a/strix/viewer/frontend/src/components/PastRunsView.tsx
+++ b/strix/viewer/frontend/src/components/PastRunsView.tsx
@@ -143,7 +143,7 @@ export default function PastRunsView({
onSelectRun(run.name)}
- className={`group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
+ className={`animate-card-in group flex w-full cursor-pointer items-center gap-4 rounded-lg border px-4 py-3 text-left transition-colors ${
active
? "border-[#444] bg-[rgba(255,255,255,0.04)]"
: "border-[#222] bg-[rgba(255,255,255,0.02)] hover:border-[#444]"
diff --git a/strix/viewer/frontend/src/components/ProCta.tsx b/strix/viewer/frontend/src/components/ProCta.tsx
index 686b4d40..80fda320 100644
--- a/strix/viewer/frontend/src/components/ProCta.tsx
+++ b/strix/viewer/frontend/src/components/ProCta.tsx
@@ -1,26 +1,5 @@
import React, { useState } from "react";
-import { ArrowUpRight } from "lucide-react";
import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
-import type { ProFeature } from "@/lib/pro-features";
-
-/**
- * Shared Pro CTA primitives. Every Pro item is a direct link-out to the cloud
- * sign-up in a new tab with a hover tooltip one-liner (no modal, no lock icon).
- * Built once here and reused by the sidebar Platform section, the top upsell
- * row, and the inline CTAs in the tabs.
- */
-
-/** Small tier pill ("Pro" or "Enterprise"). Deliberately not a padlock. */
-export function ProTag({ label = "Pro", className = "" }: { label?: string; className?: string }) {
- return (
-
- {label}
-
- );
-}
/**
* Lightweight hover tooltip. Wraps a trigger and reveals `text` above it on
@@ -58,100 +37,10 @@ export function Tooltip({
);
}
-export interface ProItem {
- title: string;
- desc: string;
- slug: string;
- icon: React.ElementType;
-}
-
/**
- * Card-style Pro feature tile: icon + name + one-liner + Pro tag + arrow.
- * Used in the top upsell row and inline CTA grids.
- */
-export function ProTile({ item, surface }: { item: ProItem; surface?: string }) {
- const Icon = item.icon;
- return (
- trackCta(item.slug, surface)}
- title={item.desc}
- className="group block cursor-pointer rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-4 text-left transition-colors hover:border-[#444]"
- >
-
- {item.title}
- {item.desc}
-
- );
-}
-
-/**
- * Sidebar-row Pro item: a two-line row (icon + label + short one-liner
- * underneath) with a small right-aligned tier tag. Opens the in-app
- * FeatureDetail view via onClick (no link-out) so it sits uniformly beside the
- * run/local rows in the themed nav list.
- */
-export function ProNavItem({
- feature,
- active,
- onClick,
- collapsed = false,
-}: {
- feature: ProFeature;
- active?: boolean;
- onClick: () => void;
- collapsed?: boolean;
-}) {
- const Icon = feature.icon;
- if (collapsed) {
- return (
-
-
-
- );
- }
- return (
-
-
-
-
- {feature.title}
-
-
- {feature.navDesc}
-
-
- );
-}
-
-/**
- * Inline Pro CTA button (compact). Used in the finding detail and per-surface
- * rows where a full card is too heavy.
+ * Compact inline CTA button that links out to sign-up in a new tab, with a
+ * hover tooltip one-liner. Used in per-surface rows where a full card is too
+ * heavy.
*/
export function ProInlineCta({
label,
@@ -177,7 +66,6 @@ export function ProInlineCta({
>
{label}
-
);
diff --git a/strix/viewer/frontend/src/components/RunDetails.tsx b/strix/viewer/frontend/src/components/RunDetails.tsx
index a387950b..e7059eba 100644
--- a/strix/viewer/frontend/src/components/RunDetails.tsx
+++ b/strix/viewer/frontend/src/components/RunDetails.tsx
@@ -72,7 +72,13 @@ export function RunDetails({
const diffMode = str(diff.mode);
const diffBase = str(raw.diff_base);
const nonInteractive = raw.non_interactive === true;
- const localSources = arr(raw.local_sources).map((x) => String(x)).filter(Boolean);
+ const localSources = arr(raw.local_sources)
+ .map((x) => {
+ if (typeof x === "string") return x;
+ const o = rec(x);
+ return str(o.source_path) ?? str(o.target_path) ?? "";
+ })
+ .filter(Boolean);
const status = cap(str(raw.status));
let scope = scopeMode ?? "auto";
@@ -146,7 +152,7 @@ export function RunDetails({
None
)}
- {scanMode && {scanMode} }
+ {scanMode && {scanMode} }
{scope}
{nonInteractive ? "Non-interactive" : "Interactive"}
{localSources.length > 0 && (
diff --git a/strix/viewer/frontend/src/components/Sidebar.tsx b/strix/viewer/frontend/src/components/Sidebar.tsx
index 6ff870c3..dc2487f2 100644
--- a/strix/viewer/frontend/src/components/Sidebar.tsx
+++ b/strix/viewer/frontend/src/components/Sidebar.tsx
@@ -1,40 +1,40 @@
-import React, { useEffect, useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
import {
- FileText,
- Bug,
- Waypoints,
+ AlertTriangle,
+ Bot,
+ Users,
History,
Mail,
- ArrowUpRight,
LogOut,
- ShieldCheck,
- PanelLeftClose,
- PanelLeft,
+ ChevronsUpDown,
} from "lucide-react";
-import { SIGNUP_URL, ctaUrl, trackCta } from "@/lib/cta";
-import { ProNavItem } from "@/components/ProCta";
-import { FEATURES, PLATFORM_ORDER } from "@/lib/pro-features";
+import { LuGitPullRequestArrow } from "react-icons/lu";
+import { VscExtensions } from "react-icons/vsc";
+import { IoChatbubblesOutline } from "react-icons/io5";
+import { cn } from "@/lib/utils";
+import { ctaUrl, trackCta } from "@/lib/cta";
+import { UpgradeModal } from "@/components/UpgradeModal";
import type { View } from "@/App";
/**
- * Persistent left rail. A single, ungrouped, ordered list of uniform two-line
- * rows (icon + label + short one-liner): the current run's views, the local
- * run-history + email-report actions, then the platform features. No section
- * headers. Tier is shown only by the inline Pro/Enterprise tag on platform
- * rows. Matches App.tsx's dark palette.
- *
- * Can collapse to a narrow icon-only rail; the collapsed state persists in
- * localStorage and each icon row keeps a `title` tooltip so the labels stay
- * discoverable.
+ * Persistent left rail: a black rail with a right hairline border, an
+ * account-switcher-style header, a single ungrouped list of h-9 nav rows (36px
+ * icon slot, 14px label, rgba(255,255,255,0.12) active fill), a hairline
+ * separator, and a user footer. Drag the right edge to resize; drag past the
+ * collapse threshold to hide it, then click the left pull-zone to bring it back.
*/
+const MIN_WIDTH = 160;
+const DEFAULT_WIDTH = 260;
+const MAX_WIDTH = 400;
+const COLLAPSE_THRESHOLD = 140;
+
+const WIDTH_KEY = "strix_viewer_sidebar_width";
const COLLAPSE_KEY = "strix_viewer_sidebar_collapsed";
interface SidebarProps {
view: View;
onSelectView: (view: View) => void;
- activeFeature: string | null;
- onSelectFeature: (slug: string) => void;
issuesCount: number;
agentCount: number;
runCount: number;
@@ -46,11 +46,19 @@ interface SidebarProps {
onForget: () => void;
}
+function readInt(key: string, fallback: number): number {
+ try {
+ const raw = localStorage.getItem(key);
+ const n = raw ? parseInt(raw, 10) : NaN;
+ return Number.isFinite(n) ? n : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
export default function Sidebar({
view,
onSelectView,
- activeFeature,
- onSelectFeature,
issuesCount,
agentCount,
runCount,
@@ -61,229 +69,367 @@ export default function Sidebar({
onOpenHistory,
onForget,
}: SidebarProps) {
- const [collapsed, setCollapsed] = useState(false);
-
- useEffect(() => {
+ const [width, setWidth] = useState(() => {
+ const w = readInt(WIDTH_KEY, DEFAULT_WIDTH);
+ return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, w));
+ });
+ const [collapsed, setCollapsed] = useState(() => {
try {
- setCollapsed(localStorage.getItem(COLLAPSE_KEY) === "1");
+ return localStorage.getItem(COLLAPSE_KEY) === "1";
} catch {
- /* localStorage may be unavailable; default to expanded */
+ return false;
+ }
+ });
+ const [isResizing, setIsResizing] = useState(false);
+ const [showUserMenu, setShowUserMenu] = useState(false);
+ const [upgradeFeature, setUpgradeFeature] = useState(null);
+ const userMenuRef = useRef(null);
+
+ // Open the upgrade dialog for a platform feature, recording which feature
+ // drove the open (the dialog's own CTAs track the deeper conversion).
+ const openUpgrade = (slug: string, description: string) => {
+ trackCta(slug, "sidebar");
+ setUpgradeFeature(description);
+ };
+
+ const persistWidth = useCallback((w: number) => {
+ setWidth(w);
+ try {
+ localStorage.setItem(WIDTH_KEY, String(w));
+ } catch {
+ /* best-effort persistence */
}
}, []);
- const toggleCollapsed = () => {
- setCollapsed((prev) => {
- const next = !prev;
- try {
- localStorage.setItem(COLLAPSE_KEY, next ? "1" : "0");
- } catch {
- /* best-effort persistence */
+ const persistCollapsed = useCallback((c: boolean) => {
+ setCollapsed(c);
+ try {
+ localStorage.setItem(COLLAPSE_KEY, c ? "1" : "0");
+ } catch {
+ /* best-effort persistence */
+ }
+ }, []);
+
+ const expandSidebar = useCallback(() => {
+ persistCollapsed(false);
+ persistWidth(DEFAULT_WIDTH);
+ }, [persistCollapsed, persistWidth]);
+
+ const handleResizeStart = useCallback((e: React.MouseEvent) => {
+ e.preventDefault();
+ setIsResizing(true);
+ }, []);
+
+ // Global drag handlers for the resize handle. Dragging below the collapse
+ // threshold hides the rail entirely.
+ useEffect(() => {
+ if (!isResizing || collapsed) return;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ const newWidth = e.clientX;
+ if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
+ setWidth(newWidth);
+ } else if (newWidth > MAX_WIDTH) {
+ setWidth(MAX_WIDTH);
}
- return next;
- });
- };
+ };
+ const handleMouseUp = (e: MouseEvent) => {
+ const finalWidth = e.clientX;
+ if (finalWidth < COLLAPSE_THRESHOLD) {
+ persistCollapsed(true);
+ persistWidth(DEFAULT_WIDTH);
+ } else {
+ persistWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, finalWidth)));
+ }
+ setIsResizing(false);
+ };
+
+ window.addEventListener("mousemove", handleMouseMove);
+ window.addEventListener("mouseup", handleMouseUp);
+ return () => {
+ window.removeEventListener("mousemove", handleMouseMove);
+ window.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [isResizing, collapsed, persistCollapsed, persistWidth]);
+
+ // Close the user menu when clicking outside it.
+ useEffect(() => {
+ if (!showUserMenu) return;
+ const onDown = (e: MouseEvent) => {
+ if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
+ setShowUserMenu(false);
+ }
+ };
+ document.addEventListener("mousedown", onDown);
+ return () => document.removeEventListener("mousedown", onDown);
+ }, [showUserMenu]);
return (
-