diff --git a/src/settings/tab-shell.tsx b/src/settings/tab-shell.tsx
index 380a16a6..41135859 100644
--- a/src/settings/tab-shell.tsx
+++ b/src/settings/tab-shell.tsx
@@ -1,8 +1,7 @@
-import type { TargetedEvent, TargetedKeyboardEvent, ComponentChild as UiNode } from "preact";
-import { useEffect, useState } from "preact/hooks";
+import type { ComponentChild as UiNode } from "preact";
import { DEFAULT_CODEX_PATH } from "../constants";
-import { IconButton } from "../shared/ui/components.obsidian";
+import { IconButton, ObsidianCommitTextInput, ObsidianDropdown, ObsidianToggle } from "../shared/ui/components.obsidian";
import type { SendShortcut } from "../shared/ui/keyboard";
import { ArchivedThreadSection } from "./archived-section";
import { HelperSettingsSection } from "./helper-section";
@@ -14,6 +13,10 @@ const SEND_SHORTCUT_LABELS = {
enter: "Enter",
"mod-enter": "Cmd/Ctrl+Enter",
} as const;
+const SEND_SHORTCUT_OPTIONS: { value: SendShortcut; label: string }[] = [
+ { value: "enter", label: SEND_SHORTCUT_LABELS.enter },
+ { value: "mod-enter", label: SEND_SHORTCUT_LABELS["mod-enter"] },
+];
interface SettingsTabPanelState {
codexPath: string;
@@ -75,10 +78,10 @@ function PanelPreferenceSections({ panel, actions }: { panel: SettingsTabPanelSt
-
+
-
+
@@ -86,72 +89,22 @@ function PanelPreferenceSections({ panel, actions }: { panel: SettingsTabPanelSt
-
+ options={SEND_SHORTCUT_OPTIONS}
+ />
-
+
>
);
}
-
-function CommitTextInput({
- value,
- placeholder,
- onCommit,
-}: {
- value: string;
- placeholder: string;
- onCommit: (value: string) => void;
-}): UiNode {
- const [draft, setDraft] = useState(value);
- useEffect(() => {
- setDraft(value);
- }, [value]);
- const commit = (nextValue = draft): void => {
- onCommit(nextValue);
- };
- return (
- {
- setDraft(event.currentTarget.value);
- }}
- onBlur={(event) => {
- commit(event.currentTarget.value);
- }}
- onKeyDown={(event: TargetedKeyboardEvent) => {
- if (event.key !== "Enter") return;
- event.preventDefault();
- commit(event.currentTarget.value);
- }}
- />
- );
-}
-
-function SettingsCheckbox({ checked, onChange }: { checked: boolean; onChange: (value: boolean) => void }): UiNode {
- return (
- ) => {
- onChange(event.currentTarget.checked);
- }}
- />
- );
-}
diff --git a/src/shared/ui/components.obsidian.tsx b/src/shared/ui/components.obsidian.tsx
index c9357b96..72967a6a 100644
--- a/src/shared/ui/components.obsidian.tsx
+++ b/src/shared/ui/components.obsidian.tsx
@@ -14,6 +14,12 @@ export interface ObsidianDropdownOption {
label: string;
}
+function useLatestRef(value: T): { current: T } {
+ const ref = useRef(value);
+ ref.current = value;
+ return ref;
+}
+
function ObsidianIcon({ icon, className }: ObsidianIconProps): UiNode {
const ref = useRef(null);
useLayoutEffect(() => {
@@ -115,6 +121,7 @@ export function ObsidianDropdown({
onChange: (value: string) => void;
}): UiNode {
const ref = useRef(null);
+ const onChangeRef = useLatestRef(onChange);
useLayoutEffect(() => {
const container = ref.current;
if (!container) return;
@@ -124,12 +131,12 @@ export function ObsidianDropdown({
dropdown.addOption(option.value, option.label);
}
dropdown.setValue(value).onChange((selected) => {
- onChange(selected);
+ onChangeRef.current(selected);
});
return () => {
container.empty();
};
- }, [onChange, options, value]);
+ }, [onChangeRef, options, value]);
return ;
}
@@ -144,6 +151,7 @@ export function ObsidianTextInput({
onChange: (value: string) => void;
}): UiNode {
const ref = useRef(null);
+ const onChangeRef = useLatestRef(onChange);
useLayoutEffect(() => {
const container = ref.current;
if (!container) return;
@@ -153,30 +161,67 @@ export function ObsidianTextInput({
.setPlaceholder(placeholder)
.setValue(value)
.onChange((nextValue) => {
- onChange(nextValue);
+ onChangeRef.current(nextValue);
});
return () => {
container.empty();
};
- }, [onChange, placeholder, value]);
+ }, [onChangeRef, placeholder, value]);
+
+ return ;
+}
+
+export function ObsidianCommitTextInput({
+ value,
+ placeholder,
+ onCommit,
+}: {
+ value: string;
+ placeholder: string;
+ onCommit: (value: string) => void;
+}): UiNode {
+ const ref = useRef(null);
+ const onCommitRef = useLatestRef(onCommit);
+ useLayoutEffect(() => {
+ const container = ref.current;
+ if (!container) return;
+ container.empty();
+ const text = new TextComponent(container);
+ text.setPlaceholder(placeholder).setValue(value);
+ const commit = () => {
+ onCommitRef.current(text.inputEl.value);
+ };
+ return disposeDomListeners(
+ listenDomEvent(text.inputEl, "blur", commit),
+ listenDomEvent(text.inputEl, "keydown", (event) => {
+ if (event.key !== "Enter") return;
+ event.preventDefault();
+ commit();
+ }),
+ () => {
+ container.empty();
+ },
+ );
+ }, [onCommitRef, placeholder, value]);
return ;
}
export function ObsidianToggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }): UiNode {
const ref = useRef(null);
+ const onChangeRef = useLatestRef(onChange);
useLayoutEffect(() => {
const container = ref.current;
if (!container) return;
container.empty();
const toggle = new ToggleComponent(container);
toggle.setValue(checked).onChange((nextValue) => {
- onChange(nextValue);
+ onChangeRef.current(nextValue);
});
return () => {
container.empty();
};
- }, [checked, onChange]);
+ }, [checked, onChangeRef]);
return ;
}
diff --git a/tests/settings/settings-tab.test.ts b/tests/settings/settings-tab.test.ts
index d9c8d8ca..94ac375e 100644
--- a/tests/settings/settings-tab.test.ts
+++ b/tests/settings/settings-tab.test.ts
@@ -84,9 +84,12 @@ describe("settings tab", () => {
const tab = newSettingsTab({ saveSettings });
tab.display();
- expect(inputForSetting(tab, "Codex executable")?.getAttribute("aria-label")).toBeNull();
+ const codexPath = inputForSetting(tab, "Codex executable");
+ expect(codexPath?.getAttribute("aria-label")).toBeNull();
+ expect(codexPath?.type).toBe("text");
const shortcut = selectForSetting(tab, "Send shortcut");
if (!shortcut) throw new Error("Missing send shortcut dropdown");
+ expect(shortcut.classList.contains("dropdown")).toBe(true);
shortcut.value = "mod-enter";
shortcut.dispatchEvent(new Event("change"));
@@ -105,6 +108,7 @@ describe("settings tab", () => {
tab.display();
const toggle = inputForSetting(tab, "Show chat toolbar");
if (!toggle) throw new Error("Missing toolbar visibility toggle");
+ expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
toggle.checked = false;
toggle.dispatchEvent(new Event("change"));
@@ -122,6 +126,7 @@ describe("settings tab", () => {
tab.display();
const toggle = inputForSetting(tab, "Scroll thread from composer line edges");
if (!toggle) throw new Error("Missing composer line edge scroll toggle");
+ expect(toggle.parentElement?.classList.contains("checkbox-container")).toBe(true);
toggle.checked = true;
toggle.dispatchEvent(new Event("change"));