Tint the proxy's header by its status, sharing Yaak's environment tint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and Stetta task tk_mwaz7asqhsi6jowm committed 2026-11-10 11:06:03 -07:00
1 parent 29a173a07e
commit bba92c5a23
8 files changed
+195 -34

No files matched your search

+3 -15
View File
@@ -1,10 +1,10 @@
import { platform } from "@yaakapp-internal/platform"; import { platform } from "@yaakapp-internal/platform";
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models"; import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui"; import { Banner, HeaderSize, HeaderTint, HStack, SidebarLayout } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import * as m from "motion/react-m"; import * as m from "motion/react-m";
import { useMemo, useState } from "react"; import { useState } from "react";
import { import {
getActiveCookieJar, getActiveCookieJar,
useEnsureActiveCookieJar, useEnsureActiveCookieJar,
@@ -61,12 +61,6 @@ export function Workspace() {
const activeEnvironment = useAtomValue(activeEnvironmentAtom); const activeEnvironment = useAtomValue(activeEnvironmentAtom);
const [floating, setFloating] = useState(false); const [floating, setFloating] = useState(false);
const environmentBgStyle = useMemo(() => {
if (activeEnvironment?.color == null) return undefined;
const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;
return { background };
}, [activeEnvironment?.color]);
if (workspaces.length === 0) { if (workspaces.length === 0) {
return <RedirectToLatestWorkspace />; return <RedirectToLatestWorkspace />;
} }
@@ -81,13 +75,7 @@ export function Workspace() {
useNativeTitlebar={settings.useNativeTitlebar} useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale} interfaceScale={settings.interfaceScale}
> >
<div className="absolute inset-0 pointer-events-none"> <HeaderTint color={activeEnvironment?.color ?? null} />
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
<div
style={environmentBgStyle}
className="absolute left-0 right-0 -bottom-px h-px opacity-20"
/>
</div>
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} /> <WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
</HeaderSize> </HeaderSize>
); );
+5 -1
View File
@@ -1,5 +1,6 @@
import { import {
HeaderSize, HeaderSize,
HeaderTint,
IconButton, IconButton,
SidebarLayout, SidebarLayout,
SplitLayout, SplitLayout,
@@ -13,7 +14,8 @@ import { useLocalStorage } from "react-use";
import { flowLayoutAtom } from "../lib/atoms"; import { flowLayoutAtom } from "../lib/atoms";
import { setupIncomplete } from "../lib/firstRun"; import { setupIncomplete } from "../lib/firstRun";
import { useMenuExportHar } from "../lib/flowActions"; import { useMenuExportHar } from "../lib/flowActions";
import { lockedAtom, noFlowsAtom, systemProxyEverOnAtom } from "../lib/store"; import { proxyStatusOf, statusTintColor } from "../lib/proxyStatus";
import { lockedAtom, noFlowsAtom, proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
import { getOsType } from "../lib/tauri"; import { getOsType } from "../lib/tauri";
import { AddColumnDialog } from "./AddColumnDialog"; import { AddColumnDialog } from "./AddColumnDialog";
import { ClearFlowsButton } from "./ClearFlowsButton"; import { ClearFlowsButton } from "./ClearFlowsButton";
@@ -43,6 +45,7 @@ export function ProxyLayout() {
); );
const [floating, setFloating] = useState(false); const [floating, setFloating] = useState(false);
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false); const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
const tint = statusTintColor(proxyStatusOf(useAtomValue(proxyStateAtom)));
return ( return (
<div <div
@@ -60,6 +63,7 @@ export function ProxyLayout() {
interfaceScale={1} interfaceScale={1}
className="x-theme-appHeader bg-surface" className="x-theme-appHeader bg-surface"
> >
<HeaderTint color={tint} animate />
{/* Yaak's silhouette: the sidebar toggle and the state pill where Yaak has the toggle and {/* Yaak's silhouette: the sidebar toggle and the state pill where Yaak has the toggle and
the environment, the search where it names the request, then its icons. Only the the environment, the search where it names the request, then its icons. Only the
controls take the pointer: everything around them, the traffic lights' corner controls take the pointer: everything around them, the traffic lights' corner
+13 -17
View File
@@ -19,6 +19,7 @@ import { useRpcQuery } from "../hooks/useRpcQuery";
import { helperRegisteredAtAtom } from "../lib/atoms"; import { helperRegisteredAtAtom } from "../lib/atoms";
import { encryptingPercent } from "../lib/encrypting"; import { encryptingPercent } from "../lib/encrypting";
import { environmentExports } from "../lib/env"; import { environmentExports } from "../lib/env";
import { proxyStatusOf } from "../lib/proxyStatus";
import { proxyStateAtom, systemProxyEverOnAtom } from "../lib/store"; import { proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
import { helperPollInterval, recordingLabel, systemProxySwitch } from "../lib/systemProxy"; import { helperPollInterval, recordingLabel, systemProxySwitch } from "../lib/systemProxy";
import { onWindowFocus } from "../lib/tauri"; import { onWindowFocus } from "../lib/tauri";
@@ -44,25 +45,26 @@ export function ProxyStatus() {
const other = proxyState?.otherOwner ?? null; const other = proxyState?.otherOwner ?? null;
const port = proxyState?.port ?? other?.proxyPort ?? null; const port = proxyState?.port ?? other?.proxyPort ?? null;
const untrusted = caStatus.data != null && !caStatus.data.trusted; const untrusted = caStatus.data != null && !caStatus.data.trusted;
// Traffic stored before encryption is being encrypted; the engine starts once it is. // The same status the header's tint takes its colour from
const encrypting = !running && other == null ? encryptingPercent(proxyState) : null; const status = proxyStatusOf(proxyState);
const encrypting = status === "encrypting" ? encryptingPercent(proxyState) : null;
const stateRow = const stateRow =
encrypting != null ? ( status === "encrypting" ? (
<MenuRow <MenuRow
icon={<LoadingIcon size="sm" className="text-text-subtle" />} icon={<LoadingIcon size="sm" className="text-text-subtle" />}
title={`Encrypting stored traffic, ${encrypting}`} title={`Encrypting stored traffic, ${encrypting}`}
description="Traffic captured before this version is being encrypted on disk. Recording starts when it's done." description="Traffic captured before this version is being encrypted on disk. Recording starts when it's done."
/> />
) : other != null ? ( ) : status === "cli" && other != null ? (
<MenuRow <MenuRow
icon={<StateDot state="cli" />} icon={<StateDot state="cli" />}
title="Running from the CLI" title="Running from the CLI"
description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`} description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`}
/> />
) : running ? ( ) : status === "recording" || status === "paused" ? (
<MenuRow <MenuRow
icon={<StateDot state={recording ? "recording" : "paused"} />} icon={<StateDot state={status} />}
title={recording ? `Recording on port ${port}` : `Paused on port ${port}`} title={recording ? `Recording on port ${port}` : `Paused on port ${port}`}
description={ description={
recording recording
@@ -190,20 +192,14 @@ export function ProxyStatus() {
return ( return (
<> <>
<Dropdown ref={dropdownRef} items={items}> <Dropdown ref={dropdownRef} items={items}>
<Pill <Pill color={status === "stopped" ? "warning" : undefined}>
color={ {status === "encrypting" ? (
!running && other == null && encrypting == null && proxyState != null
? "warning"
: undefined
}
>
{encrypting != null ? (
<> <>
<LoadingIcon size="xs" /> <LoadingIcon size="xs" />
<span className="text-text">Encrypting</span> <span className="text-text">Encrypting</span>
<span className="font-mono text-text-subtle">{encrypting}</span> <span className="font-mono text-text-subtle">{encrypting}</span>
</> </>
) : other != null ? ( ) : status === "cli" && other != null ? (
<> <>
<StateDot state="cli" /> <StateDot state="cli" />
<span className="text-text">CLI</span> <span className="text-text">CLI</span>
@@ -211,9 +207,9 @@ export function ProxyStatus() {
<span className="font-mono text-text-subtle">:{other.proxyPort}</span> <span className="font-mono text-text-subtle">:{other.proxyPort}</span>
)} )}
</> </>
) : running ? ( ) : status === "recording" || status === "paused" ? (
<> <>
<StateDot state={recording ? "recording" : "paused"} /> <StateDot state={status} />
<span className="text-text">{recording ? "Recording" : "Paused"}</span> <span className="text-text">{recording ? "Recording" : "Paused"}</span>
{port != null && <span className="font-mono text-text-subtle">:{port}</span>} {port != null && <span className="font-mono text-text-subtle">:{port}</span>}
</> </>
+26 -1
View File
@@ -41,6 +41,31 @@ export const HARNESS_STATE: GetProxyStateResponse = {
otherOwner: null, otherOwner: null,
}; };
/** The engine's state as `?status=paused`, `stopped`, `failed` or `cli` asks; recording otherwise */
function harnessState(status: string | null): GetProxyStateResponse {
const stopped = { ...HARNESS_STATE, state: "stopped" as const, port: null };
switch (status) {
case "paused":
return { ...HARNESS_STATE, recording: false };
case "stopped":
return stopped;
case "failed":
return { ...stopped, startError: "Port 9090 is in use by another process." };
case "cli":
return {
...stopped,
otherOwner: {
owner: "cli",
pid: 4242,
startedAt: new Date().toISOString(),
proxyPort: 9090,
},
};
default:
return HARNESS_STATE;
}
}
function answer(cmd: string, payload: unknown): unknown { function answer(cmd: string, payload: unknown): unknown {
const req = (payload ?? {}) as Record<string, unknown>; const req = (payload ?? {}) as Record<string, unknown>;
switch (cmd) { switch (cmd) {
@@ -62,7 +87,7 @@ function answer(cmd: string, payload: unknown): unknown {
case "app_icon": case "app_icon":
return appIconPng; return appIconPng;
case "get_proxy_state": case "get_proxy_state":
return HARNESS_STATE; return harnessState(new URLSearchParams(location.search).get("status"));
case "get_storage_size": case "get_storage_size":
return { bytes: 412 * 1024 * 1024 }; return { bytes: 412 * 1024 * 1024 };
case "ca_status": case "ca_status":
+70
View File
@@ -0,0 +1,70 @@
import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib";
import { describe, expect, test } from "vite-plus/test";
import { proxyStatusOf, statusTintColor } from "./proxyStatus";
const base: GetProxyStateResponse = {
state: "running",
port: 9090,
configuredPort: 9090,
recording: true,
systemProxyAtLaunch: true,
httpVersion: "auto",
captureBodyLimit: 10 * 1024 * 1024,
traffic: "keep",
keepTrafficBytes: null,
startError: null,
encrypting: null,
locked: null,
otherOwner: null,
};
const tint = (patch: Partial<GetProxyStateResponse> | null) =>
statusTintColor(proxyStatusOf(patch == null ? null : { ...base, ...patch }));
const cli: GetProxyStateResponse["otherOwner"] = {
owner: "cli",
pid: 42,
startedAt: "2026-11-10T10:00:00Z",
proxyPort: 9091,
};
describe("proxyStatusOf and statusTintColor", () => {
test("recording takes the success colour", () => {
expect(proxyStatusOf(base)).toBe("recording");
expect(tint({})).toBe("var(--success)");
});
test("paused takes the warning colour", () => {
expect(proxyStatusOf({ ...base, recording: false })).toBe("paused");
expect(tint({ recording: false })).toBe("var(--warning)");
});
test("not running takes the danger colour", () => {
expect(proxyStatusOf({ ...base, state: "stopped", port: null })).toBe("stopped");
expect(tint({ state: "stopped", port: null })).toBe("var(--danger)");
});
test("failing to start takes the danger colour", () => {
expect(tint({ state: "stopped", port: null, startError: "Port 9090 is in use" })).toBe(
"var(--danger)",
);
});
test("locked traffic takes the danger colour", () => {
const locked = { message: "Traffic is locked", keychain: true } as NonNullable<
GetProxyStateResponse["locked"]
>;
expect(tint({ state: "stopped", port: null, locked })).toBe("var(--danger)");
});
test("an engine the CLI owns takes the info colour", () => {
expect(proxyStatusOf({ ...base, state: "stopped", port: null, otherOwner: cli })).toBe("cli");
expect(tint({ state: "stopped", port: null, otherOwner: cli })).toBe("var(--info)");
});
test("no tint while encrypting or before the state is read", () => {
expect(proxyStatusOf({ ...base, state: "stopped", encrypting: 0.4 })).toBe("encrypting");
expect(tint({ state: "stopped", encrypting: 0.4 })).toBeNull();
expect(tint(null)).toBeNull();
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { GetProxyStateResponse } from "@yaakapp-internal/proxy-lib";
/**
* What the engine is doing, as the header shows it: the pill's dot and the header's tint both
* read this, so they can't disagree.
*/
export type ProxyStatusKind = "recording" | "paused" | "cli" | "stopped" | "encrypting";
/** The status of a proxy state, or null while it hasn't been read yet */
export function proxyStatusOf(
state: GetProxyStateResponse | null | undefined,
): ProxyStatusKind | null {
if (state == null) return null;
// Traffic stored before encryption is being encrypted; the engine starts once it is.
if (state.state !== "running" && state.otherOwner == null && state.encrypting != null) {
return "encrypting";
}
if (state.otherOwner != null) return "cli";
if (state.locked != null || state.state !== "running") return "stopped";
return state.recording === false ? "paused" : "recording";
}
/** The header's tint for a status, from the theme's colours; null for none */
export function statusTintColor(status: ProxyStatusKind | null): string | null {
switch (status) {
case "recording":
return "var(--success)";
case "paused":
return "var(--warning)";
case "stopped":
return "var(--danger)";
case "cli":
return "var(--info)";
default:
return null;
}
}
+40
View File
@@ -0,0 +1,40 @@
import classNames from "classnames";
import type { CSSProperties } from "react";
interface HeaderTintProps {
/** Any CSS colour, or null for no tint */
color: string | null;
/** Fade between colours when the colour changes */
animate?: boolean;
}
// Drawn in currentColor so a change of colour can fade: a gradient itself can't transition.
const GRADIENT: CSSProperties = {
background: "linear-gradient(to right, currentColor 15%, transparent 40%)",
};
/**
* A colour wash from a header's left edge, with a line of it along the bottom border, placed
* inside a `HeaderSize`. It takes no pointer, so clicks and window drags reach the header.
*/
export function HeaderTint({ color, animate }: HeaderTintProps) {
return (
<div
aria-hidden
style={{ color: isColor(color) ? color : "transparent" }}
className={classNames(
"absolute inset-0 pointer-events-none",
animate && "transition-[color] duration-200",
)}
>
<div style={GRADIENT} className="absolute inset-0 opacity-[0.07]" />
<div style={GRADIENT} className="absolute left-0 right-0 -bottom-px h-px opacity-20" />
</div>
);
}
/** An unreadable colour draws nothing, rather than falling back to the inherited text colour */
function isColor(color: string | null): color is string {
if (color == null || color === "") return false;
return typeof CSS === "undefined" || CSS.supports("color", color);
}
+1
View File
@@ -136,6 +136,7 @@ export type { FormattableEditor } from "./components/FormatAction";
export { FormatAction, formatEditor } from "./components/FormatAction"; export { FormatAction, formatEditor } from "./components/FormatAction";
export { FormattedError } from "./components/FormattedError"; export { FormattedError } from "./components/FormattedError";
export { HeaderSize } from "./components/HeaderSize"; export { HeaderSize } from "./components/HeaderSize";
export { HeaderTint } from "./components/HeaderTint";
export type { HeadersTableProps } from "./components/HeadersTable"; export type { HeadersTableProps } from "./components/HeadersTable";
export { HeadersTable } from "./components/HeadersTable"; export { HeadersTable } from "./components/HeadersTable";
export { Heading } from "./components/Heading"; export { Heading } from "./components/Heading";