Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { Component, Match, Switch, createSignal, onMount } from "solid-js";
import ChatFeed from "./components/ChatFeed";
import Header from "./components/Header";
import SignIn from "./components/SignIn";
import { getAuthStatus, type AuthStatusState } from "./lib/twitchAuth";
import { getAuthStatus, type AuthStatus } from "./lib/twitchAuth";

const App: Component = () => {
// `null` = still loading initial status; the splash avoids a flash of
// the SignIn overlay before the keychain check returns.
const [authState, setAuthState] = createSignal<AuthStatusState | null>(null);
const [auth, setAuth] = createSignal<AuthStatus | null>(null);

onMount(async () => {
try {
const status = await getAuthStatus();
setAuthState(status.state);
setAuth(await getAuthStatus());
} catch {
// Treat any error from the status command as logged-out — the
// SignIn flow surfaces a real error message if the underlying
// keychain is broken.
setAuthState("logged_out");
setAuth({ state: "logged_out" });
}
});

Expand All @@ -25,7 +25,7 @@ const App: Component = () => {
style={{ display: "flex", "flex-direction": "column", height: "100%" }}
>
<Switch>
<Match when={authState() === null}>
<Match when={auth() === null}>
<div
style={{
display: "flex",
Expand All @@ -39,11 +39,23 @@ const App: Component = () => {
Loading...
</div>
</Match>
<Match when={authState() === "logged_out"}>
<SignIn onAuthenticated={() => setAuthState("logged_in")} />
<Match when={auth()?.state === "logged_out"}>
<SignIn
onAuthenticated={(login) => setAuth({ state: "logged_in", login })}
/>
</Match>
<Match when={authState() === "logged_in"}>
<ChatFeed />
<Match
when={(() => {
const a = auth();
return a?.state === "logged_in" ? a : null;
})()}
>
{(loggedIn) => (
<>
<Header login={loggedIn().login} />
<ChatFeed />
</>
)}
</Match>
</Switch>
</div>
Expand Down
128 changes: 128 additions & 0 deletions apps/desktop/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Top chrome for the chat window. Shows the active channel, the platform
// it belongs to, and a live connection indicator fed by the supervisor's
// `sidecar_status` event. Purely presentational: no commands invoked, so
// a broken supervisor can't brick the UI.

import { Component, Show, createSignal, onCleanup, onMount } from "solid-js";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import {
indicatorFor,
type SidecarState,
type SidecarStatus,
} from "../lib/sidecarStatus";

export interface HeaderProps {
login: string;
}

const Header: Component<HeaderProps> = (props) => {
const [state, setState] = createSignal<SidecarState | null>(null);
let unlisten: UnlistenFn | undefined;
// listen() is async, so a fast unmount can race the registration: if
// onCleanup runs first, `unlisten` is still undefined and the listener
// would leak when the promise resolves later. Track disposal explicitly
// so the late-arriving handle can be torn down immediately.
let disposed = false;

onMount(() => {
listen<SidecarStatus>("sidecar_status", (evt) => {
setState(evt.payload.state);
})
.then((next) => {
if (disposed) {
next();
return;
}
unlisten = next;
})
.catch((err: unknown) => {
console.error("failed to subscribe to sidecar_status", err);
});
});

onCleanup(() => {
disposed = true;
unlisten?.();
Comment thread
ImpulseB23 marked this conversation as resolved.
unlisten = undefined;
});

return (
<header
style={{
display: "flex",
"align-items": "center",
gap: "10px",
padding: "8px 12px",
"border-bottom": "1px solid #2a2a2e",
background: "#18181b",
"font-family":
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
"font-size": "13px",
color: "#efeff1",
"flex-shrink": 0,
}}
>
<span
title="Twitch"
aria-label="Twitch"
style={{
display: "inline-flex",
"align-items": "center",
"justify-content": "center",
width: "20px",
height: "20px",
"border-radius": "4px",
background: "#9146ff",
color: "#fff",
"font-weight": 700,
"font-size": "12px",
}}
>
T
</span>
<span style={{ "font-weight": 600 }}>{props.login}</span>
<span style={{ flex: 1 }} />
<ConnectionChip state={state()} />
</header>
);
};

const ConnectionChip: Component<{ state: SidecarState | null }> = (props) => {
const info = () => indicatorFor(props.state);
return (
<span
data-testid="connection-chip"
data-state={props.state ?? "initial"}
style={{
display: "inline-flex",
"align-items": "center",
gap: "6px",
padding: "2px 8px",
"border-radius": "10px",
background: "#1f1f23",
border: "1px solid #2a2a2e",
"font-size": "12px",
color: "#c8c8d0",
}}
>
<Dot color={info().color} />
<Show when={info().label}>{info().label}</Show>
</span>
);
};

const Dot: Component<{ color: string }> = (props) => {
return (
<span
style={{
display: "inline-block",
width: "8px",
height: "8px",
"border-radius": "50%",
background: props.color,
}}
/>
);
};

export default Header;
47 changes: 47 additions & 0 deletions apps/desktop/src/lib/sidecarStatus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { indicatorFor } from "./sidecarStatus";

describe("indicatorFor", () => {
it("running → green connected", () => {
expect(indicatorFor("running")).toEqual({
label: "Connected",
color: "#3fb950",
});
});

it("spawning and backoff share the amber connecting indicator", () => {
expect(indicatorFor("spawning")).toEqual({
label: "Connecting",
color: "#d29922",
});
expect(indicatorFor("backoff")).toEqual({
label: "Connecting",
color: "#d29922",
});
});

it("waiting_for_auth surfaces a sign-in hint", () => {
expect(indicatorFor("waiting_for_auth")).toEqual({
label: "Waiting for sign-in",
color: "#d29922",
});
});

it("unhealthy and terminated are distinct", () => {
expect(indicatorFor("unhealthy")).toEqual({
label: "Unhealthy",
color: "#db6d28",
});
expect(indicatorFor("terminated")).toEqual({
label: "Disconnected",
color: "#f85149",
});
});

it("null before any event reports a starting state", () => {
expect(indicatorFor(null)).toEqual({
label: "Starting",
color: "#6e7681",
});
});
});
40 changes: 40 additions & 0 deletions apps/desktop/src/lib/sidecarStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Pure state→indicator mapping shared with the header component.
// Split out so unit tests don't need to resolve the Solid/Tauri
// component module graph.

export type SidecarState =
| "spawning"
| "waiting_for_auth"
| "backoff"
| "running"
| "unhealthy"
| "terminated";

export interface SidecarStatus {
state: SidecarState;
attempt: number;
backoff_ms?: number;
}

export interface Indicator {
label: string;
color: string;
}

export function indicatorFor(state: SidecarState | null): Indicator {
switch (state) {
case "running":
return { label: "Connected", color: "#3fb950" };
case "spawning":
case "backoff":
return { label: "Connecting", color: "#d29922" };
case "waiting_for_auth":
return { label: "Waiting for sign-in", color: "#d29922" };
case "unhealthy":
return { label: "Unhealthy", color: "#db6d28" };
case "terminated":
return { label: "Disconnected", color: "#f85149" };
default:
return { label: "Starting", color: "#6e7681" };
}
}
11 changes: 7 additions & 4 deletions apps/desktop/src/lib/twitchAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import { open } from "@tauri-apps/plugin-shell";

export type AuthStatusState = "logged_out" | "logged_in";

export interface AuthStatus {
state: AuthStatusState;
login?: string;
}
// Discriminated union: when state is logged_in, login is guaranteed by
// the backend (twitch_auth::commands::twitch_auth_status). Modeling it
// this way prevents the UI from silently rendering an empty username if
// a malformed payload ever slips through.
export type AuthStatus =
| { state: "logged_out" }
| { state: "logged_in"; login: string };

export interface DeviceCodeView {
verification_uri: string;
Expand Down
Loading