Skip to content

Commit 774dfa7

Browse files
committed
feat: support light mode
1 parent dba4388 commit 774dfa7

7 files changed

Lines changed: 270 additions & 123 deletions

File tree

src/App.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,36 @@ const homesResponse = {
4545

4646
describe("App Codex home flow", () => {
4747
beforeEach(() => {
48+
localStorage.clear();
49+
delete document.documentElement.dataset.theme;
50+
document.documentElement.style.colorScheme = "";
4851
invokeMock.mockReset().mockResolvedValue(homesResponse);
4952
resetSessionMock.mockReset().mockResolvedValue(undefined);
5053
resetPickerMock.mockReset().mockResolvedValue(undefined);
5154
discoverSessionsMock.mockReset().mockResolvedValue(undefined);
5255
});
5356

57+
it("defaults to dark mode and persists theme changes", async () => {
58+
render(<App />);
59+
60+
expect(document.documentElement).toHaveAttribute("data-theme", "dark");
61+
fireEvent.click(screen.getByRole("button", { name: "Switch to light mode" }));
62+
63+
expect(document.documentElement).toHaveAttribute("data-theme", "light");
64+
expect(document.documentElement.style.colorScheme).toBe("light");
65+
expect(localStorage.getItem("codex-trace-theme")).toBe("light");
66+
expect(screen.getByRole("button", { name: "Switch to dark mode" })).toBeInTheDocument();
67+
});
68+
69+
it("restores a persisted light theme", () => {
70+
localStorage.setItem("codex-trace-theme", "light");
71+
72+
render(<App />);
73+
74+
expect(document.documentElement).toHaveAttribute("data-theme", "light");
75+
expect(screen.getByRole("button", { name: "Switch to dark mode" })).toBeInTheDocument();
76+
});
77+
5478
it("auto-selects the existing single-home deployment", async () => {
5579
invokeMock.mockResolvedValue({
5680
homes: [{ id: "default", name: "Default", sessions_dir: "/default/sessions" }],

src/App.tsx

Lines changed: 118 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useSession } from "./hooks/useSession";
44
import { usePicker } from "./hooks/usePicker";
55
import { useCodexHomes } from "./hooks/useCodexHomes";
66
import { useToggleSet } from "./hooks/useToggleSet";
7+
import { ThemeContext, useTheme } from "./hooks/useTheme";
78
import { useKeyboard } from "./hooks/useKeyboard";
89
import { SidebarTree } from "./components/SidebarTree";
910
import { SessionPicker } from "./components/SessionPicker";
@@ -30,6 +31,7 @@ function findToolByCallId(tools: CodexToolCall[], callId: string): CodexToolCall
3031
}
3132

3233
export function App() {
34+
const { theme, toggleTheme } = useTheme();
3335
const [view, setView] = useState<ViewState>("homes");
3436
const [selectedTurn, setSelectedTurn] = useState(0);
3537
const [pickerSelected, setPickerSelected] = useState(0);
@@ -222,128 +224,132 @@ export function App() {
222224
});
223225

224226
return (
225-
<div className="app">
226-
{/* Info bar — only when session loaded and not in picker */}
227-
{session.sessionPath && view !== "picker" && session.session && (
228-
<InfoBar session={session.session} />
229-
)}
230-
231-
{/* View toolbar */}
232-
<ViewToolbar
233-
view={view}
234-
hasSession={!!session.sessionPath}
235-
activeHomeName={codexHomes.activeHome?.name ?? null}
236-
canSwitchHomes={codexHomes.homes.length > 1}
237-
canOpenSettings={view !== "homes" && !codexHomes.multiHomeEnabled}
238-
onGoToSessions={goToSessions}
239-
onSwitchHomes={() => void handleSwitchHomes()}
240-
onExpandAll={expandAll}
241-
onCollapseAll={collapseAll}
242-
onOpenSettings={() => setShowSettings(true)}
243-
/>
244-
245-
<div className="app-body">
246-
{/* Left sidebar */}
247-
{view !== "homes" && (
248-
<>
249-
<div className="app__sidebar" style={{ width: sidebarWidth, minWidth: sidebarWidth }}>
250-
<div className="app__sidebar-header">
251-
<span className="app__sidebar-title">SESSIONS</span>
252-
</div>
253-
<SidebarTree
254-
sessions={picker.allSessions}
255-
selectedPath={session.sessionPath || null}
256-
collapsedDates={collapsedDates}
257-
onSelectSession={handleSelectSession}
258-
onToggleDate={handleToggleDate}
259-
/>
260-
</div>
261-
262-
<ResizeHandle onResize={setSidebarWidth} />
263-
</>
227+
<ThemeContext.Provider value={theme}>
228+
<div className="app">
229+
{/* Info bar — only when session loaded and not in picker */}
230+
{session.sessionPath && view !== "picker" && session.session && (
231+
<InfoBar session={session.session} />
264232
)}
265233

266-
{/* Main content */}
267-
<div className="main-content">
268-
{view === "homes" && (
269-
<CodexHomeSelector
270-
homes={codexHomes.homes}
271-
loading={codexHomes.loading}
272-
error={codexHomes.error}
273-
selectedIndex={codexHomes.selectedIndex}
274-
onSelect={(home) => void handleSelectHome(home)}
275-
onRetry={() => void discoverHomes(true)}
276-
/>
277-
)}
278-
279-
{view === "picker" && (
280-
<SessionPicker
281-
sessions={picker.sessions}
282-
loading={picker.loading}
283-
searchQuery={picker.searchQuery}
284-
selectedIndex={pickerSelected}
285-
onSelectSession={handleSelectSession}
286-
onSearchChange={picker.setSearchQuery}
287-
/>
288-
)}
234+
{/* View toolbar */}
235+
<ViewToolbar
236+
view={view}
237+
hasSession={!!session.sessionPath}
238+
activeHomeName={codexHomes.activeHome?.name ?? null}
239+
canSwitchHomes={codexHomes.homes.length > 1}
240+
canOpenSettings={view !== "homes" && !codexHomes.multiHomeEnabled}
241+
onGoToSessions={goToSessions}
242+
onSwitchHomes={() => void handleSwitchHomes()}
243+
onExpandAll={expandAll}
244+
onCollapseAll={collapseAll}
245+
onOpenSettings={() => setShowSettings(true)}
246+
theme={theme}
247+
onToggleTheme={toggleTheme}
248+
/>
289249

290-
{view === "list" && session.loading && (
291-
<div className="app__loading">Loading session…</div>
292-
)}
250+
<div className="app-body">
251+
{/* Left sidebar */}
252+
{view !== "homes" && (
253+
<>
254+
<div className="app__sidebar" style={{ width: sidebarWidth, minWidth: sidebarWidth }}>
255+
<div className="app__sidebar-header">
256+
<span className="app__sidebar-title">SESSIONS</span>
257+
</div>
258+
<SidebarTree
259+
sessions={picker.allSessions}
260+
selectedPath={session.sessionPath || null}
261+
collapsedDates={collapsedDates}
262+
onSelectSession={handleSelectSession}
263+
onToggleDate={handleToggleDate}
264+
/>
265+
</div>
293266

294-
{view === "list" && !session.loading && session.session && (
295-
<TurnList
296-
turns={turns}
297-
selectedIndex={selectedTurn}
298-
onSelectTurn={(i) => {
299-
setSelectedTurn(i);
300-
setView("detail");
301-
}}
302-
/>
267+
<ResizeHandle onResize={setSidebarWidth} />
268+
</>
303269
)}
304270

305-
{view === "detail" && turns[selectedTurn] && (
306-
<TurnDetail
307-
turn={turns[selectedTurn]}
308-
expanded={expandedTools}
309-
onToggle={toggleTool}
310-
onBack={() => setView("list")}
311-
openWorkerCallId={workerPanelCallId}
312-
onOpenWorkerPanel={handleOpenWorkerPanel}
313-
/>
271+
{/* Main content */}
272+
<div className="main-content">
273+
{view === "homes" && (
274+
<CodexHomeSelector
275+
homes={codexHomes.homes}
276+
loading={codexHomes.loading}
277+
error={codexHomes.error}
278+
selectedIndex={codexHomes.selectedIndex}
279+
onSelect={(home) => void handleSelectHome(home)}
280+
onRetry={() => void discoverHomes(true)}
281+
/>
282+
)}
283+
284+
{view === "picker" && (
285+
<SessionPicker
286+
sessions={picker.sessions}
287+
loading={picker.loading}
288+
searchQuery={picker.searchQuery}
289+
selectedIndex={pickerSelected}
290+
onSelectSession={handleSelectSession}
291+
onSearchChange={picker.setSearchQuery}
292+
/>
293+
)}
294+
295+
{view === "list" && session.loading && (
296+
<div className="app__loading">Loading session…</div>
297+
)}
298+
299+
{view === "list" && !session.loading && session.session && (
300+
<TurnList
301+
turns={turns}
302+
selectedIndex={selectedTurn}
303+
onSelectTurn={(i) => {
304+
setSelectedTurn(i);
305+
setView("detail");
306+
}}
307+
/>
308+
)}
309+
310+
{view === "detail" && turns[selectedTurn] && (
311+
<TurnDetail
312+
turn={turns[selectedTurn]}
313+
expanded={expandedTools}
314+
onToggle={toggleTool}
315+
onBack={() => setView("list")}
316+
openWorkerCallId={workerPanelCallId}
317+
onOpenWorkerPanel={handleOpenWorkerPanel}
318+
/>
319+
)}
320+
</div>
321+
322+
{view === "detail" && workerPanelTool?.worker_session && (
323+
<>
324+
<ResizeHandle onResize={setWorkerPanelWidth} side="right" />
325+
<WorkerPanel
326+
session={workerPanelTool.worker_session}
327+
sourceTool={workerPanelTool}
328+
activeWorkerCallId={workerPanelCallId}
329+
style={{ flex: `0 0 ${workerPanelWidth}px`, maxWidth: workerPanelWidth }}
330+
onClose={closeWorkerPanel}
331+
onOpenWorker={handleOpenWorkerPanel}
332+
/>
333+
</>
314334
)}
315335
</div>
316336

317-
{view === "detail" && workerPanelTool?.worker_session && (
318-
<>
319-
<ResizeHandle onResize={setWorkerPanelWidth} side="right" />
320-
<WorkerPanel
321-
session={workerPanelTool.worker_session}
322-
sourceTool={workerPanelTool}
323-
activeWorkerCallId={workerPanelCallId}
324-
style={{ flex: `0 0 ${workerPanelWidth}px`, maxWidth: workerPanelWidth }}
325-
onClose={closeWorkerPanel}
326-
onOpenWorker={handleOpenWorkerPanel}
327-
/>
328-
</>
337+
{/* Bottom keybind bar */}
338+
<KeybindBar
339+
view={view}
340+
showHints={showKeybinds}
341+
onToggle={() => setShowKeybinds((p) => !p)}
342+
/>
343+
344+
{showSettings && !codexHomes.multiHomeEnabled && (
345+
<SettingsModal
346+
onClose={() => setShowSettings(false)}
347+
onSaved={(dir) => {
348+
discoverSessions(dir);
349+
}}
350+
/>
329351
)}
330352
</div>
331-
332-
{/* Bottom keybind bar */}
333-
<KeybindBar
334-
view={view}
335-
showHints={showKeybinds}
336-
onToggle={() => setShowKeybinds((p) => !p)}
337-
/>
338-
339-
{showSettings && !codexHomes.multiHomeEnabled && (
340-
<SettingsModal
341-
onClose={() => setShowSettings(false)}
342-
onSaved={(dir) => {
343-
discoverSessions(dir);
344-
}}
345-
/>
346-
)}
347-
</div>
353+
</ThemeContext.Provider>
348354
);
349355
}

src/components/MarkdownRenderer.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import ReactMarkdown from "react-markdown";
22
import remarkGfm from "remark-gfm";
33
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
4-
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
4+
import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
55
import { formatJson } from "../lib/format";
6+
import { useCurrentTheme } from "../hooks/useTheme";
67

78
function isPureJson(s: string): boolean {
89
const t = s.trimStart();
@@ -16,9 +17,12 @@ function isPureJson(s: string): boolean {
1617
}
1718

1819
export function MarkdownRenderer({ content }: { content: string }) {
20+
const theme = useCurrentTheme();
21+
const syntaxTheme = theme === "light" ? oneLight : oneDark;
22+
1923
if (isPureJson(content)) {
2024
return (
21-
<SyntaxHighlighter language="json" style={oneDark} PreTag="div">
25+
<SyntaxHighlighter language="json" style={syntaxTheme} PreTag="div">
2226
{formatJson(content)}
2327
</SyntaxHighlighter>
2428
);
@@ -34,7 +38,7 @@ export function MarkdownRenderer({ content }: { content: string }) {
3438
const code = String(children).replace(/\n$/, "");
3539
if (lang) {
3640
return (
37-
<SyntaxHighlighter language={lang} style={oneDark} PreTag="div">
41+
<SyntaxHighlighter language={lang} style={syntaxTheme} PreTag="div">
3842
{code}
3943
</SyntaxHighlighter>
4044
);

src/components/ViewToolbar.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { ViewState } from "../../shared/types";
22
import { IoMdSettings } from "react-icons/io";
3+
import { MdDarkMode, MdLightMode } from "react-icons/md";
4+
import type { Theme } from "../hooks/useTheme";
35

46
interface ViewToolbarProps {
57
view: ViewState;
@@ -12,6 +14,8 @@ interface ViewToolbarProps {
1214
onExpandAll: () => void;
1315
onCollapseAll: () => void;
1416
onOpenSettings: () => void;
17+
theme: Theme;
18+
onToggleTheme: () => void;
1519
}
1620

1721
function scrollContent(to: "top" | "bottom") {
@@ -30,6 +34,8 @@ export function ViewToolbar({
3034
onExpandAll,
3135
onCollapseAll,
3236
onOpenSettings,
37+
theme,
38+
onToggleTheme,
3339
}: ViewToolbarProps) {
3440
return (
3541
<div className="view-toolbar">
@@ -62,6 +68,15 @@ export function ViewToolbar({
6268
</>
6369
)}
6470
<span className="view-toolbar__spacer" />
71+
<button
72+
className="view-toolbar__btn view-toolbar__theme-toggle"
73+
onClick={onToggleTheme}
74+
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
75+
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
76+
>
77+
{theme === "dark" ? <MdLightMode /> : <MdDarkMode />}
78+
<span>{theme === "dark" ? "Light" : "Dark"}</span>
79+
</button>
6580
{canOpenSettings && (
6681
<button className="view-toolbar__btn" onClick={onOpenSettings} title="Settings (,) ">
6782
<IoMdSettings />

0 commit comments

Comments
 (0)