Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ vi.mock('./logging/AutoLogService', () => ({
createAutoLogSessionDraft: vi.fn(() => ({})),
}));

// Diagnostics wires itself up against the real preferences store shape
// (state.diagnostics.enabled); this test's mockPreferences doesn't model
// every preference domain, so stub the whole side-effecting module out.
vi.mock('./diagnostics', () => ({}));

import App from './App';
import { useConnectionStore } from './stores/connectionStore';

Expand Down
15 changes: 9 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import WasmGuest from "./components/WasmGuest";
import type { WasmHostState } from "./components/WasmHost";
import WasmHost from "./components/WasmHost";
import { createConfiguredClient } from "./createConfiguredClient";
// Side-effect import: wires diagnostics capture up to the "Capture
// diagnostics" preference as soon as the app loads.
import "./diagnostics";
import type { GMCPMessageRoomInfo } from "./gmcp/Room";
import { createHapticsRuntime, type HapticsRuntime } from "./haptics/runtime";
import { useChannelHistory } from "./hooks/useChannelHistory";
Expand Down Expand Up @@ -110,8 +113,8 @@ function App() {

const clientInitialized = useRef(false);
const hapticsRuntimeRef = useRef<HapticsRuntime | null>(null);
const midiEnabled = usePreferences((state) => state.midi.enabled);
const hapticsEnabled = usePreferences((state) => state.haptics.enabled);
const midiEnabled = usePreferences((state) => state.midi.enabled);
const hapticsEnabled = usePreferences((state) => state.haptics.enabled);
const connected = useConnectionStore((state) => state.connected);
const sessionReady = useConnectionStore((state) => state.sessionReady);
useFileTransferNotifications(client);
Expand Down Expand Up @@ -335,7 +338,7 @@ function App() {
}, [handleAppKeyDown]);

useEffect(() => {
if (!midiEnabled) return;
if (!midiEnabled) return;

let cancelled = false;
import("./VirtualMidiService")
Expand All @@ -356,7 +359,7 @@ function App() {
return () => {
cancelled = true;
};
}, [midiEnabled]);
}, [midiEnabled]);

// Window subtitle tracks the current room from the room store. On disconnect
// the client resets the store, which clears roomInfo and so clears the subtitle.
Expand All @@ -373,8 +376,8 @@ function App() {
}, [client, roomInfo]);

useEffect(() => {
hapticsRuntimeRef.current?.setEnabled(hapticsEnabled);
}, [hapticsEnabled]);
hapticsRuntimeRef.current?.setEnabled(hapticsEnabled);
}, [hapticsEnabled]);

const handleCommand = useCallback(
(text: string) => {
Expand Down
14 changes: 14 additions & 0 deletions src/EditorManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ export class EditorManager {
this.setupChannelListeners();
}

/**
* Editor windows that are still open. Read by the diagnostics and
* performance tooling when attributing main-thread work.
*/
get openEditorCount(): number {
let count = 0;
for (const session of this.editors.values()) {
if (session.state !== EditorState.Closed && session.window && !session.window.closed) {
count += 1;
}
}
return count;
}

openEditorWindow(editorSession: EditorSession) {
console.log('Opening editor window for session:', editorSession);
const id = editorSession.reference;
Expand Down
6 changes: 6 additions & 0 deletions src/FileTransferManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ export default class FileTransferManager extends EventEmitter {
new Map();
private store: FileTransferStore;
private storeInitialized: boolean = false;

/** Count of transfers in flight or awaiting a response, for diagnostics/status reporting. */
get activeTransferCount(): number {
return this.incomingTransfers.size + this.outgoingTransfers.size + this.pendingOffers.size;
}

private readonly handleDataChannelMessage = (data: ArrayBuffer): void => {
void this.handleIncomingChunk(data);
};
Expand Down
77 changes: 77 additions & 0 deletions src/components/preferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { announce } from "@react-aria/live-announcer";
import type { AutoreadMode, NavigationKeyScheme } from "../stores/preferencesStore";
import { usePreferences } from "../stores/preferencesStore";
import { useVoices } from "../hooks/useVoices";
import { copyDiagnosticsToClipboard } from "../diagnostics";
import Tabs, { type TabProps } from "./tabs";
import AutoLogDialog, { type AutoLogDialogRef } from "./AutoLogDialog";

Expand Down Expand Up @@ -478,6 +479,81 @@ const AutologgingTab: React.FC = () => {
);
};

const CopyDiagnosticsButton: React.FC<{ redactMessageText: boolean }> = ({
redactMessageText,
}) => {
const [state, setState] = useState<"default" | "copied" | "error">("default");

const handleClick = async () => {
try {
await copyDiagnosticsToClipboard(redactMessageText);
setState("copied");
announce("Diagnostics copied to clipboard", "polite");
} catch (error) {
console.error("Failed to copy diagnostics:", error);
setState("error");
announce("Failed to copy diagnostics", "assertive");
} finally {
setTimeout(() => setState("default"), 1500);
}
};

const label = state === "copied" ? "Copied!" : state === "error" ? "Error" : "Copy diagnostics";

return (
<button type="button" onClick={handleClick}>
{label}
</button>
);
};

const DiagnosticsTab: React.FC = () => {
const diagnostics = usePreferences((state) => state.diagnostics);
const setDiagnostics = usePreferences((state) => state.setDiagnostics);

return (
<div>
<label>
<input
type="checkbox"
checked={diagnostics.enabled}
onChange={(e) =>
setDiagnostics({ ...diagnostics, enabled: e.target.checked })
}
aria-describedby="diagnostics-help"
/>
Capture diagnostics
</label>
<br />
<br />
<p id="diagnostics-help" style={{ color: "var(--color-text-secondary)", fontSize: "0.9em" }}>
While enabled, the client keeps a small in-memory log of connection events,
console warnings/errors, and performance counters, to help diagnose problems.
Nothing is saved to disk; it disappears when the tab closes.
</p>
<p style={{ color: "var(--color-text-secondary)", fontSize: "0.9em" }}>
<strong>Before you copy:</strong> the exported report includes your character
name, room names, and other session details, and — unless redacted below —
may include the text of recent messages. It never includes passwords, tokens,
or credentials. Review it before pasting into a public GitHub issue.
</p>
<label>
<input
type="checkbox"
checked={diagnostics.redactMessageText}
onChange={(e) =>
setDiagnostics({ ...diagnostics, redactMessageText: e.target.checked })
}
/>
Redact message text when copying
</label>
<br />
<br />
<CopyDiagnosticsButton redactMessageText={diagnostics.redactMessageText} />
</div>
);
};

const Preferences: React.FC = () => {
const tabs: TabProps[] = [
{ id: "preferences-general-tab", label: "General", content: <GeneralTab /> },
Expand All @@ -488,6 +564,7 @@ const Preferences: React.FC = () => {
{ id: "preferences-midi-tab", label: "MIDI", content: <MidiTab /> },
{ id: "preferences-haptics-tab", label: "Haptics", content: <HapticsTab /> },
{ id: "preferences-autologging-tab", label: "Logging", content: <AutologgingTab /> },
{ id: "preferences-diagnostics-tab", label: "Diagnostics", content: <DiagnosticsTab /> },
];

return <Tabs tabs={tabs} ariaLabel="Preferences sections" />;
Expand Down
78 changes: 78 additions & 0 deletions src/diagnostics/connectionCapture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useConnectionStore } from "../stores/connectionStore";
import { DiagnosticsRingBuffer } from "./ringBuffer";
import { startConnectionCapture } from "./connectionCapture";

describe("startConnectionCapture", () => {
beforeEach(() => {
useConnectionStore.getState().reset();
});

afterEach(() => {
useConnectionStore.getState().reset();
});

it("records the first connection without counting it as a reconnect", () => {
const buffer = new DiagnosticsRingBuffer();
buffer.setEnabled(true);
const stop = startConnectionCapture(buffer);

useConnectionStore.getState().setConnected(true);

const snapshot = buffer.snapshot();
expect(snapshot).toHaveLength(1);
expect(snapshot[0]).toMatchObject({
category: "connection",
data: { event: "connected", reconnectCount: 0 },
});

stop();
});

it("increments reconnectCount on subsequent reconnects", () => {
const buffer = new DiagnosticsRingBuffer();
buffer.setEnabled(true);
const stop = startConnectionCapture(buffer);

useConnectionStore.getState().setConnected(true);
useConnectionStore.getState().setConnected(false);
useConnectionStore.getState().setConnected(true);

const events = buffer.snapshot().map((r) => r.data);
expect(events).toEqual([
{ event: "connected", reconnectCount: 0 },
{ event: "disconnected", statusText: "Disconnected", reconnectCount: 0 },
{ event: "connected", reconnectCount: 1 },
]);

stop();
});

it("records status text changes that aren't connect/disconnect", () => {
const buffer = new DiagnosticsRingBuffer();
buffer.setEnabled(true);
const stop = startConnectionCapture(buffer);

useConnectionStore.getState().setStatusText("Reconnecting...");

const snapshot = buffer.snapshot();
expect(snapshot).toHaveLength(1);
expect(snapshot[0]).toMatchObject({
category: "connection",
data: { event: "status", statusText: "Reconnecting..." },
});

stop();
});

it("stops recording after the returned unsubscribe is called", () => {
const buffer = new DiagnosticsRingBuffer();
buffer.setEnabled(true);
const stop = startConnectionCapture(buffer);
stop();

useConnectionStore.getState().setConnected(true);

expect(buffer.snapshot()).toHaveLength(0);
});
});
40 changes: 40 additions & 0 deletions src/diagnostics/connectionCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useConnectionStore } from "../stores/connectionStore";
import { type DiagnosticsRingBuffer, diagnosticsBuffer } from "./ringBuffer";

/**
* Records connection lifecycle transitions (connect / disconnect / status
* text updates) into the diagnostics buffer, and tracks a running reconnect
* count (the number of times the client reconnected after an initial
* connection was lost).
*
* Returns an unsubscribe function.
*/
export function startConnectionCapture(
buffer: DiagnosticsRingBuffer = diagnosticsBuffer,
): () => void {
let everConnected = useConnectionStore.getState().connected;
let reconnectCount = 0;

return useConnectionStore.subscribe((state, previousState) => {
if (state.connected !== previousState.connected) {
if (state.connected) {
if (everConnected) {
reconnectCount += 1;
}
everConnected = true;
buffer.record("connection", { event: "connected", reconnectCount });
} else {
buffer.record("connection", {
event: "disconnected",
statusText: state.statusText,
reconnectCount,
});
}
return;
}

if (state.statusText !== previousState.statusText) {
buffer.record("connection", { event: "status", statusText: state.statusText });
}
});
}
72 changes: 72 additions & 0 deletions src/diagnostics/consoleCapture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DiagnosticsRingBuffer } from "./ringBuffer";
import { capMessage, installConsoleCapture } from "./consoleCapture";

describe("capMessage", () => {
it("returns short strings unchanged", () => {
expect(capMessage("hello")).toBe("hello");
});

it("truncates long strings and appends an ellipsis", () => {
const long = "x".repeat(600);
const capped = capMessage(long, 500);
expect(capped.length).toBe(501);
expect(capped.endsWith("…")).toBe(true);
});
});

describe("installConsoleCapture", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("preserves original console.warn/error behavior", () => {
const buffer = new DiagnosticsRingBuffer();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const originalWarn = console.warn;
const uninstall = installConsoleCapture(buffer);

console.warn("careful now");

expect(warnSpy).toHaveBeenCalledWith("careful now");
uninstall();
expect(console.warn).toBe(originalWarn);
});

it("records warn/error calls only while the buffer is enabled", () => {
const buffer = new DiagnosticsRingBuffer();
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
const uninstall = installConsoleCapture(buffer);

console.warn("not captured yet");
expect(buffer.snapshot()).toHaveLength(0);

buffer.setEnabled(true);
console.warn("captured");
console.error("also captured");

const snapshot = buffer.snapshot();
expect(snapshot).toHaveLength(2);
expect(snapshot[0]).toMatchObject({ category: "console.warn", data: { message: "captured" } });
expect(snapshot[1]).toMatchObject({ category: "console.error", data: { message: "also captured" } });

uninstall();
});

it("caps long messages and joins multiple arguments", () => {
const buffer = new DiagnosticsRingBuffer();
buffer.setEnabled(true);
vi.spyOn(console, "error").mockImplementation(() => {});
const uninstall = installConsoleCapture(buffer);

console.error("prefix:", "x".repeat(600));

const [record] = buffer.snapshot();
const message = record.data.message as string;
expect(message.startsWith("prefix:")).toBe(true);
expect(message.length).toBeLessThanOrEqual(501);

uninstall();
});
});
Loading