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
2 changes: 1 addition & 1 deletion src/components/graph/graph-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,7 @@ export function GraphView() {
{/* Graph canvas */}
<div
ref={graphContainerRef}
className="relative flex-1 min-w-0 overflow-hidden bg-slate-50 dark:bg-slate-950"
className="relative flex-1 min-w-0 overflow-hidden bg-background"
onContextMenu={(e) => e.preventDefault()}
onClick={() => setNodeMenu(null)}
>
Expand Down
2 changes: 1 addition & 1 deletion src/components/project/welcome-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function WelcomeScreen({
}

return (
<div className="flex h-full items-center justify-center bg-background">
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-8 px-4">
<div className="text-center">
<h1 className="text-3xl font-bold">{t("app.title")}</h1>
Expand Down
38 changes: 37 additions & 1 deletion src/components/settings/sections/interface-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,21 @@ import type { SettingsDraft, DraftSetter } from "../settings-types"
interface Props {
draft: SettingsDraft
setDraft: DraftSetter
onThemeChange?: (theme: "light" | "dark" | "system") => void
}

const UI_LANGUAGES = [
{ value: "en", label: "English" },
{ value: "zh", label: "中文" },
]

export function InterfaceSection({ draft, setDraft }: Props) {
const THEMES = [
{ value: "light" as const, labelKey: "settings.sections.interface.themeLight" },
{ value: "dark" as const, labelKey: "settings.sections.interface.themeDark" },
{ value: "system" as const, labelKey: "settings.sections.interface.themeSystem" },
]

export function InterfaceSection({ draft, setDraft, onThemeChange }: Props) {
const { t } = useTranslation()
return (
<div className="space-y-6">
Expand Down Expand Up @@ -48,6 +55,35 @@ export function InterfaceSection({ draft, setDraft }: Props) {
{t("settings.sections.interface.uiLanguageHint")}
</p>
</div>

<div className="space-y-2">
<Label>{t("settings.sections.interface.theme")}</Label>
<div className="flex flex-wrap gap-2">
{THEMES.map((th) => {
const active = draft.theme === th.value
return (
<button
key={th.value}
type="button"
onClick={() => {
setDraft("theme", th.value)
onThemeChange?.(th.value)
}}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
active
? "border-primary bg-primary text-primary-foreground"
: "border-border hover:bg-accent"
}`}
>
{t(th.labelKey)}
</button>
)
})}
</div>
<p className="text-xs text-muted-foreground">
{t("settings.sections.interface.themeHint")}
</p>
</div>
</div>
)
}
1 change: 1 addition & 0 deletions src/components/settings/settings-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export interface SettingsDraft {

// UI
uiLanguage: string
theme: "light" | "dark" | "system"

// Source folder auto watch
sourceWatchConfig: SourceWatchConfig
Expand Down
42 changes: 40 additions & 2 deletions src/components/settings/settings-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { Button } from "@/components/ui/button"
import { useWikiStore } from "@/stores/wiki-store"
import { useChatStore } from "@/stores/chat-store"
import { useUpdateStore, hasAvailableUpdate } from "@/stores/update-store"
import { loadSourceWatchConfig, saveLanguage } from "@/lib/project-store"
import { loadSourceWatchConfig, saveLanguage, saveTheme, loadTheme } from "@/lib/project-store"
import type { SettingsDraft, DraftSetter } from "./settings-types"
import { normalizeSourceWatchConfig } from "@/lib/source-watch-config"
import { LlmProviderSection } from "./sections/llm-provider-section"
Expand Down Expand Up @@ -90,6 +90,7 @@ function initialDraft(
maxHistoryMessages: number,
uiLanguage: string,
projectPath?: string,
theme?: "light" | "dark" | "system",
): SettingsDraft {
// Show absolute path: if stored path is empty, show default using project path
// If stored path is relative (legacy), prepend project path
Expand Down Expand Up @@ -145,6 +146,24 @@ function initialDraft(
apiAllowUnauthenticated: apiConfig.allowUnauthenticated,
apiToken: apiConfig.token,
uiLanguage,
theme: theme ?? "system",
}
}

function applyTheme(theme: "light" | "dark" | "system") {
const root = document.documentElement
if (theme === "system") {
// Remove the class and let the media query handle it
root.classList.remove("light", "dark")
// Check system preference
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
root.classList.add("dark")
} else {
root.classList.add("light")
}
} else {
root.classList.remove("light", "dark")
root.classList.add(theme)
}
}

Expand Down Expand Up @@ -181,6 +200,7 @@ export function SettingsView() {

const [active, setActive] = useState<CategoryId>("llm")
const [saved, setSaved] = useState(false)
const [currentTheme, setCurrentTheme] = useState<"light" | "dark" | "system">("system")
const [draft, setDraftState] = useState<SettingsDraft>(() =>
initialDraft(
llmConfig,
Expand All @@ -197,6 +217,16 @@ export function SettingsView() {
),
)

// Load theme on mount
useEffect(() => {
loadTheme().then((theme) => {
if (theme) {
setCurrentTheme(theme)
setDraftState((prev) => ({ ...prev, theme }))
}
}).catch(() => {})
}, [])

useEffect(() => {
let cancelled = false
loadSourceWatchConfig(project?.id).then((config) => {
Expand Down Expand Up @@ -394,6 +424,14 @@ export function SettingsView() {
await saveLanguage(draft.uiLanguage)
}

// Save theme
if (draft.theme !== currentTheme) {
await saveTheme(draft.theme)
setCurrentTheme(draft.theme)
// Apply theme immediately
applyTheme(draft.theme)
}

setSaved(true)
setTimeout(() => setSaved(false), 2000)
}, [
Expand Down Expand Up @@ -435,7 +473,7 @@ export function SettingsView() {
case "output":
return <OutputSection draft={draft} setDraft={setDraft} />
case "interface":
return <InterfaceSection draft={draft} setDraft={setDraft} />
return <InterfaceSection draft={draft} setDraft={setDraft} onThemeChange={applyTheme} />
case "maintenance":
return <MaintenanceSection />
case "changelog":
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,12 @@
"title": "Interface",
"description": "UI language and appearance. Changes apply immediately and persist.",
"uiLanguage": "UI Language",
"uiLanguageHint": "Affects labels, buttons, and other UI strings only. The AI output language is set separately in Output Preferences."
"uiLanguageHint": "Affects labels, buttons, and other UI strings only. The AI output language is set separately in Output Preferences.",
"theme": "Theme",
"themeHint": "Choose between light, dark, or system theme. System theme follows your operating system's appearance setting.",
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System"
},
"maintenance": {
"title": "Maintenance",
Expand Down
7 changes: 6 additions & 1 deletion src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,12 @@
"title": "界面",
"description": "界面语言和外观样式。切换后立即生效并持久化。",
"uiLanguage": "UI 语言",
"uiLanguageHint": "只影响按钮、标签这些 UI 文案,不影响 AI 输出语言(那个在\"输出偏好\"里单独设置)。"
"uiLanguageHint": "只影响按钮、标签这些 UI 文案,不影响 AI 输出语言(那个在\"输出偏好\"里单独设置)。",
"theme": "主题",
"themeHint": "选择浅色、深色或跟随系统主题。系统主题会跟随操作系统的外观设置。",
"themeLight": "浅色",
"themeDark": "深色",
"themeSystem": "跟随系统"
},
"maintenance": {
"title": "维护",
Expand Down
18 changes: 9 additions & 9 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -84,34 +84,34 @@
}

.dark {
--background: oklch(0.145 0 0);
--background: oklch(0.16 0.005 260);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: oklch(0.205 0.005 260);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.205 0.005 260);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary: oklch(0.269 0.005 260);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted: oklch(0.269 0.005 260);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent: oklch(0.269 0.005 260);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar: oklch(0.18 0.005 260);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent: oklch(0.24 0.005 260);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
Expand Down
11 changes: 10 additions & 1 deletion src/lib/ingest-queue.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ describe("ingest-queue persistence — write", () => {
return false
}
})
// Add small delay to ensure file is fully written
await new Promise(resolve => setTimeout(resolve, 50))
const parsed = JSON.parse(await readQueueFile())
expect(parsed[0].sourcePath).toBe("raw/sources/a.md")
})
Expand All @@ -105,12 +107,15 @@ describe("ingest-queue persistence — write", () => {
])
await waitFor(async () => {
try {
const parsed = JSON.parse(await readQueueFile())
const c = await readQueueFile()
const parsed = JSON.parse(c)
return parsed.length === 1
} catch {
return false
}
})
// Add small delay to ensure file is fully written
await new Promise(resolve => setTimeout(resolve, 50))
const parsed = JSON.parse(await readQueueFile())
expect(parsed).toHaveLength(1)
expect(parsed[0].sourcePath).toBe("raw/sources/a.md")
Expand All @@ -129,6 +134,8 @@ describe("ingest-queue persistence — write", () => {
return false
}
})
// Add small delay to ensure file is fully written
await new Promise(resolve => setTimeout(resolve, 50))
const parsed = JSON.parse(await readQueueFile()) as Array<{ sourcePath: string; folderContext: string }>
const paths = parsed.map((p) => p.sourcePath)
expect(paths).toContain("raw/sources/注意力机制.pdf")
Expand Down Expand Up @@ -269,6 +276,8 @@ describe("ingest-queue persistence — restore round-trip", () => {
return false
}
})
// Add small delay to ensure file is fully written
await new Promise(resolve => setTimeout(resolve, 50))
// Verify on-disk state before we blow away memory
const onDisk = JSON.parse(await readQueueFile()) as Array<{ sourcePath: string; folderContext: string }>
expect(onDisk[0].sourcePath).toBe("raw/sources/注意力.pdf")
Expand Down
4 changes: 2 additions & 2 deletions src/lib/ingest-source-path-collision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,11 +522,11 @@ describe("autoIngest source summary paths", () => {
)

const canonicalSummary = `wiki/sources/${sourceSummarySlugFromIdentity("project-a/config.yaml")}.md`
const canonicalSummaryPath = path.join(tmp.path, canonicalSummary)
const canonicalSummaryPath = path.join(tmp.path, canonicalSummary).replace(/\\/g, "/")
const staleSummaryPath = path.join(tmp.path, "wiki", "sources", "config.md")
const content = await fs.readFile(canonicalSummaryPath, "utf8")

expect(writtenPaths).toEqual([canonicalSummaryPath])
expect(writtenPaths.map((p) => p.replace(/\\/g, "/"))).toEqual([canonicalSummaryPath])
await expect(fs.access(staleSummaryPath)).rejects.toThrow()
expect(content).toContain('sources: ["project-a/config.yaml"]')
})
Expand Down
12 changes: 12 additions & 0 deletions src/lib/project-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,18 @@ export async function loadLanguage(): Promise<string | null> {
return (await store.get<string>(LANGUAGE_KEY)) ?? null
}

const THEME_KEY = "theme"

export async function saveTheme(theme: "light" | "dark" | "system"): Promise<void> {
const store = await getStore()
await store.set(THEME_KEY, theme)
}

export async function loadTheme(): Promise<"light" | "dark" | "system" | null> {
const store = await getStore()
return (await store.get<"light" | "dark" | "system">(THEME_KEY)) ?? null
}

const OUTPUT_LANGUAGE_KEY = "outputLanguage"
const PROJECT_OUTPUT_LANGUAGE_KEY = "projectOutputLanguages"
const PROJECT_FILE_SYNC_KEY = "projectFileSyncEnabled"
Expand Down
45 changes: 40 additions & 5 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,44 @@ import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import "@/i18n";
import { loadTheme } from "@/lib/project-store";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// Apply theme before render to avoid flash
async function initApp() {
const root = document.documentElement;

// Try to load saved theme
try {
const savedTheme = await loadTheme();
if (savedTheme) {
if (savedTheme === "system") {
// Follow system preference
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
root.classList.add("dark");
} else {
root.classList.add("light");
}
} else {
root.classList.add(savedTheme);
}
} else {
// Default to system preference
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
root.classList.add("dark");
} else {
root.classList.add("light");
}
}
} catch {
// Default to light if loading fails
root.classList.add("light");
}

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
}

initApp();