mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
2de2e95e75
configStore.ensureLoaded writes every registry default into a cookie on first settings access, on every surface including the guides.show viewer, so the webmcpTools entry wrote plannotator-webmcp-tools=true for every user whether or not the browser had document.modelContext. The preference now lives in packages/ui/webmcp/preference.ts: read lazily, written only as false when the user opts out, removed again when the tools are turned back on. The portable viewer bundle is byte-identical to main again.
2546 lines
125 KiB
TypeScript
2546 lines
125 KiB
TypeScript
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal';
|
|
import type { Origin } from '@plannotator/core/agents';
|
|
import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
|
|
import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config';
|
|
import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
|
|
import { loadDiffFont } from '../utils/diffFonts';
|
|
import { TaterSpritePullup } from './TaterSpritePullup';
|
|
import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity';
|
|
import { GitUser } from '../icons/GitUser';
|
|
import {
|
|
getObsidianSettings,
|
|
saveObsidianSettings,
|
|
getEffectiveVaultPath,
|
|
CUSTOM_PATH_SENTINEL,
|
|
DEFAULT_FILENAME_FORMAT,
|
|
type ObsidianSettings,
|
|
} from '../utils/obsidian';
|
|
import {
|
|
getBearSettings,
|
|
saveBearSettings,
|
|
normalizeTags,
|
|
type BearSettings,
|
|
} from '../utils/bear';
|
|
import {
|
|
getOctarineSettings,
|
|
saveOctarineSettings,
|
|
type OctarineSettings,
|
|
} from '../utils/octarine';
|
|
import {
|
|
getAgentSwitchSettings,
|
|
getAgentSwitchDefaults,
|
|
saveAgentSwitchSettings,
|
|
AGENT_OPTIONS,
|
|
type AgentSwitchSettings,
|
|
type AgentSwitchSurface,
|
|
} from '../utils/agentSwitch';
|
|
import {
|
|
getPlanSaveSettings,
|
|
savePlanSaveSettings,
|
|
type PlanSaveSettings,
|
|
} from '../utils/planSave';
|
|
import {
|
|
getUIPreferences,
|
|
saveUIPreferences,
|
|
PLAN_WIDTH_OPTIONS,
|
|
type UIPreferences,
|
|
type PlanWidth,
|
|
} from '../utils/uiPreferences';
|
|
import {
|
|
getPermissionModeSettings,
|
|
savePermissionModeSettings,
|
|
PERMISSION_MODE_OPTIONS,
|
|
type PermissionMode,
|
|
} from '../utils/permissionMode';
|
|
import { getAutoCloseDelay, setAutoCloseDelay, AUTO_CLOSE_OPTIONS, type AutoCloseDelay } from '../utils/storage';
|
|
import {
|
|
getDefaultNotesApp,
|
|
saveDefaultNotesApp,
|
|
type DefaultNotesApp,
|
|
} from '../utils/defaultNotesApp';
|
|
import { useAgents } from '../hooks/useAgents';
|
|
import { KeyboardShortcuts } from './KeyboardShortcuts';
|
|
import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels';
|
|
import { ThemeTab } from './ThemeTab';
|
|
import { isMac, modKey, altKey } from '../utils/platform';
|
|
import { getAIProviderSettings, resolveAIProviderSelection } from '../utils/aiProvider';
|
|
import { AISettingsTab } from './AISettingsTab';
|
|
import { HooksTab } from './settings/HooksTab';
|
|
import { OverlayScrollArea } from './OverlayScrollArea';
|
|
import {
|
|
getFileBrowserSettings,
|
|
saveFileBrowserSettings,
|
|
type FileBrowserSettings,
|
|
} from '../utils/fileBrowser';
|
|
import { requestVimDocumentFocus } from '../hooks/useVimDocumentFocus';
|
|
import { AnalysisLayerToggle } from './AnalysisLayerToggle';
|
|
|
|
type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'analysis' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks';
|
|
|
|
interface SettingsProps {
|
|
taterMode: boolean;
|
|
onTaterModeChange: (enabled: boolean) => void;
|
|
onIdentityChange?: (oldIdentity: string, newIdentity: string) => void;
|
|
origin?: Origin | null;
|
|
mode?: 'plan' | 'annotate' | 'review';
|
|
onUIPreferencesChange?: (prefs: UIPreferences) => void;
|
|
/** Externally controlled open state (for mobile menu integration) */
|
|
externalOpen?: boolean;
|
|
onExternalClose?: () => void;
|
|
/** Available AI providers (from /api/ai/capabilities). */
|
|
aiProviders?: Array<{ id: string; name: string; capabilities: Record<string, boolean>; models?: Array<{ id: string; label: string; default?: boolean }> }>;
|
|
/** Git user name from `git config user.name`, for quick identity set */
|
|
gitUser?: string;
|
|
/** Current session is a local git review where since-base ISN'T offered
|
|
* (base ref unresolvable) — the Git tab shows a note that the Git-status
|
|
* preference can't take effect in THIS repo. */
|
|
sinceBaseUnavailable?: boolean;
|
|
/** The host is rendering its compact touch shell (review only). Display
|
|
* settings that the compact shell overrides for the session are hidden
|
|
* there instead of silently editing the desktop preference. */
|
|
isCompactTouchLayout?: boolean;
|
|
/** Override Obsidian vault detection (default = GET /api/obsidian/vaults). */
|
|
onDetectObsidianVaults?: () => Promise<string[]>;
|
|
/** This annotate session actually offers the Agent TUI, so its Position
|
|
* setting is worth showing. Default false: the setting is the only way back
|
|
* from a Hidden position, but offering it where no terminal can ever run
|
|
* would just be a dead control. */
|
|
agentTerminalAvailable?: boolean;
|
|
/** The browser exposes WebMCP (`document.modelContext`), so the "Agent
|
|
* tools" opt-out is worth showing. Default false: a browser without the
|
|
* API gets no row at all (the provider registers nothing there). */
|
|
webmcpAvailable?: boolean;
|
|
}
|
|
|
|
// --- Review-mode Display tab (diff display options) ---
|
|
|
|
const DIFF_FONT_OPTIONS = [
|
|
{ value: '', label: 'Theme Default' },
|
|
{ value: 'Fira Code', label: 'Fira Code' },
|
|
{ value: 'Hack', label: 'Hack' },
|
|
{ value: 'IBM Plex Mono', label: 'IBM Plex Mono' },
|
|
{ value: 'Inconsolata', label: 'Inconsolata' },
|
|
{ value: 'JetBrains Mono', label: 'JetBrains Mono' },
|
|
{ value: 'Red Hat Mono', label: 'Red Hat Mono' },
|
|
{ value: 'Roboto Mono', label: 'Roboto Mono' },
|
|
{ value: 'Source Code Pro', label: 'Source Code Pro' },
|
|
{ value: 'Atkinson Hyperlegible Mono', label: 'Atkinson Hyperlegible' },
|
|
];
|
|
|
|
export const DIFF_STYLE_OPTIONS = [
|
|
{ value: 'split' as const, label: 'Split' },
|
|
{ value: 'unified' as const, label: 'Unified' },
|
|
];
|
|
export const OVERFLOW_OPTIONS = [
|
|
{ value: 'scroll' as const, label: 'Scroll' },
|
|
{ value: 'wrap' as const, label: 'Wrap' },
|
|
];
|
|
export const INDICATOR_OPTIONS = [
|
|
{ value: 'bars' as const, label: 'Bars' },
|
|
{ value: 'classic' as const, label: 'Classic' },
|
|
{ value: 'none' as const, label: 'None' },
|
|
];
|
|
export const LINE_DIFF_OPTIONS = [
|
|
{ value: 'word-alt' as const, label: 'Word-Alt' },
|
|
{ value: 'word' as const, label: 'Word' },
|
|
{ value: 'char' as const, label: 'Char' },
|
|
{ value: 'none' as const, label: 'None' },
|
|
];
|
|
export const LINE_BG_INTENSITY_OPTIONS: { value: DiffLineBgIntensity; label: string }[] = [
|
|
{ value: 'subtle', label: 'Subtle' },
|
|
{ value: 'normal', label: 'Normal' },
|
|
{ value: 'strong', label: 'Strong' },
|
|
];
|
|
const DEFAULT_DIFF_TYPE_OPTIONS = [
|
|
// "All Changes" belongs to since-base (the flagship composite); uncommitted
|
|
// reverts to its plain name so the two stay distinguishable side by side.
|
|
{ value: 'since-base' as const, label: 'All Changes (Recommended)', description: "Everything since your branch split from main — committed, uncommitted, and untracked" },
|
|
{ value: 'uncommitted' as const, label: 'Uncommitted', description: "Everything you've changed since your last commit" },
|
|
{ value: 'unstaged' as const, label: 'Unstaged', description: "Only changes you haven't staged yet" },
|
|
{ value: 'staged' as const, label: 'Staged', description: "Only changes you've staged for commit" },
|
|
{ value: 'merge-base' as const, label: 'Committed changes (PR view)', description: "Everything you've committed on this branch" },
|
|
{ value: 'all' as const, label: 'All Files (HEAD)', description: "Every tracked file at HEAD, shown as additions" },
|
|
];
|
|
|
|
const AGENT_TERMINAL_SIDE_OPTIONS: { value: AnnotateAgentTerminalSide; label: string }[] = [
|
|
{ value: 'left', label: 'Left' },
|
|
{ value: 'right', label: 'Right' },
|
|
{ value: 'hidden', label: 'Hidden' },
|
|
];
|
|
|
|
function SegmentedControl<T extends string>({ options, value, onChange }: {
|
|
options: { value: T; label: string }[];
|
|
value: T;
|
|
onChange: (v: T) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-1 bg-muted/50 rounded-lg p-0.5">
|
|
{options.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => onChange(opt.value)}
|
|
className={`flex-1 px-3 py-1.5 text-xs rounded-md transition-colors ${
|
|
value === opt.value
|
|
? 'bg-background text-foreground shadow-sm font-medium'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ToggleSwitch({ checked, onChange, label, description, disabled = false }: {
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
label: string;
|
|
description?: string;
|
|
disabled?: boolean;
|
|
}) {
|
|
return (
|
|
<div className={`flex items-center justify-between gap-4 ${disabled ? 'opacity-50' : ''}`}>
|
|
<div>
|
|
<div className="text-sm font-medium">{label}</div>
|
|
{description && <div className="text-xs text-muted-foreground">{description}</div>}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-label={label}
|
|
aria-checked={checked}
|
|
disabled={disabled}
|
|
onClick={() => onChange(!checked)}
|
|
className="relative inline-flex h-11 w-11 shrink-0 items-center disabled:cursor-not-allowed"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className={`absolute inset-x-0 top-2.5 h-6 rounded-full transition-colors ${
|
|
checked ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
/>
|
|
<span
|
|
aria-hidden="true"
|
|
className={`absolute top-3.5 inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
checked ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface VimSettingsTabProps {
|
|
readonly vimModeEnabled: boolean;
|
|
readonly vimHudEnabled: boolean;
|
|
readonly vimHudKeyPanelEnabled: boolean;
|
|
readonly onVimModeChange: (enabled: boolean) => void;
|
|
readonly onVimHudChange: (enabled: boolean) => void;
|
|
readonly onVimHudKeyPanelChange: (enabled: boolean) => void;
|
|
}
|
|
|
|
/** First-class Vim configuration, separate from the shortcut reference. */
|
|
function VimSettingsTab({
|
|
vimModeEnabled,
|
|
vimHudEnabled,
|
|
vimHudKeyPanelEnabled,
|
|
onVimModeChange,
|
|
onVimHudChange,
|
|
onVimHudKeyPanelChange,
|
|
}: VimSettingsTabProps) {
|
|
return (
|
|
<div className="space-y-5">
|
|
<div>
|
|
<div className="text-sm font-semibold">Vim mode</div>
|
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
Navigate, select, and annotate the rendered document without reaching for the mouse.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-4 rounded-xl border border-border bg-muted/20 p-4">
|
|
<ToggleSwitch
|
|
checked={vimModeEnabled}
|
|
onChange={onVimModeChange}
|
|
label="Vim controls"
|
|
description="Use Vim-style keys for document navigation and annotation. Off by default."
|
|
/>
|
|
<div className="border-t border-border" />
|
|
<ToggleSwitch
|
|
checked={vimModeEnabled && vimHudEnabled}
|
|
onChange={onVimHudChange}
|
|
label="Vim HUD"
|
|
description={
|
|
vimModeEnabled
|
|
? 'Show the four-corner target reticle and navigation context.'
|
|
: 'Enable Vim controls first to use the target reticle.'
|
|
}
|
|
disabled={!vimModeEnabled}
|
|
/>
|
|
<div className="border-t border-border" />
|
|
<ToggleSwitch
|
|
checked={vimModeEnabled && vimHudEnabled && vimHudKeyPanelEnabled}
|
|
onChange={onVimHudKeyPanelChange}
|
|
label="Key panel"
|
|
description={
|
|
vimModeEnabled && vimHudEnabled
|
|
? 'Show the bottom-right live keypress legend and command panel.'
|
|
: 'Enable the Vim HUD first to use the live keypress legend.'
|
|
}
|
|
disabled={!vimModeEnabled || !vimHudEnabled}
|
|
/>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-primary/20 bg-primary/[0.05] p-4">
|
|
<div className="flex items-start gap-3">
|
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-md bg-primary/15 font-mono text-xs font-bold text-primary">
|
|
?
|
|
</span>
|
|
<div>
|
|
<div className="text-xs font-semibold">Learn while you navigate</div>
|
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
Vim takes focus automatically when the page is ready. From other app controls, press{' '}
|
|
<span className="font-mono text-foreground">Esc</span> to return to the document, then
|
|
use <span className="font-mono text-foreground">j</span> and{' '}
|
|
<span className="font-mono text-foreground">k</span> to move by block. Press{' '}
|
|
<span className="font-mono text-foreground">?</span> for the complete key map,
|
|
even when the Key panel is hidden.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ReviewAnalysisTab() {
|
|
const semanticDiffEnabled = useConfigValue('semanticDiffEnabled');
|
|
const callFlowEnabled = useConfigValue('callFlowEnabled');
|
|
return (
|
|
<div className="space-y-5">
|
|
<div>
|
|
<div className="text-sm font-semibold">Analysis layers</div>
|
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
Add structural context to the ordinary code diff. Each layer is independent and does no work while disabled.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-4 rounded-xl border border-border bg-muted/20 p-4">
|
|
<AnalysisLayerToggle
|
|
checked={semanticDiffEnabled}
|
|
onChange={(enabled) => configStore.set('semanticDiffEnabled', enabled)}
|
|
label="Semantic changes"
|
|
description="Organize added, changed, moved, and removed functions, classes, and other named code. Enabled by default."
|
|
/>
|
|
<div className="border-t border-border" />
|
|
<AnalysisLayerToggle
|
|
checked={callFlowEnabled}
|
|
onChange={(enabled) => configStore.set('callFlowEnabled', enabled)}
|
|
label="Call flow"
|
|
description="Diffs for function call stacks across git commits. 22 languages supported (AST-based, built using Tree-sitter)."
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="rounded-xl border border-border p-3.5">
|
|
<div className="flex items-center gap-2 text-xs font-semibold">
|
|
<span className="grid h-6 w-6 place-items-center rounded-md bg-primary/10 font-mono text-primary">▣</span>
|
|
Dock
|
|
</div>
|
|
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
|
Open the complete analysis from the file panel. Rows jump to the relevant code location.
|
|
</p>
|
|
</div>
|
|
<div className="rounded-xl border border-border p-3.5">
|
|
<div className="flex items-center gap-2 text-xs font-semibold">
|
|
<span className="grid h-6 w-6 place-items-center rounded-md bg-primary/10 font-mono text-primary">⌁</span>
|
|
File lens
|
|
</div>
|
|
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
|
File headers show compact counts and a focused popover for that file.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-warning/25 bg-warning/[0.05] p-4 text-xs leading-relaxed text-muted-foreground">
|
|
<span className="font-semibold text-foreground">Call flow is syntactic.</span>{' '}
|
|
It does not use type resolution or runtime traces, so treat it as navigation context—not proof that a path executes.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => {
|
|
const defaultDiffType = useConfigValue('defaultDiffType');
|
|
const reviewPanelView = useConfigValue('reviewPanelView');
|
|
return (
|
|
<div className="space-y-5">
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Default review view</div>
|
|
<div className="text-xs text-muted-foreground">Which panel a code review opens in</div>
|
|
{/* This is a GLOBAL preference — never hide the options because the
|
|
CURRENT repo can't serve them; just say so. Without this note,
|
|
picking Git status on a repo whose base ref doesn't resolve
|
|
silently falls back to Tree and the setting looks broken. */}
|
|
{sinceBaseUnavailable && (
|
|
<div className="text-xs text-warning mt-1">
|
|
Git status view isn't available in this repository (its base branch
|
|
couldn't be resolved) — reviews here open in Tree. The preference
|
|
still applies in repositories where it works.
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* No Commits option here: the Commits view is session-only (entered
|
|
via the panel toggle) and is never the opening view. */}
|
|
<SegmentedControl
|
|
options={[
|
|
{ value: 'sections' as const, label: 'Git status' },
|
|
{ value: 'tree' as const, label: 'Tree' },
|
|
]}
|
|
value={reviewPanelView}
|
|
onChange={setReviewPanelView}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Default Diff View</div>
|
|
<div className="text-xs text-muted-foreground">Which changes to show when you open a code review</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{DEFAULT_DIFF_TYPE_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
type="button"
|
|
// Coupling (sections ⟺ since-base) lives in the shared setter —
|
|
// never write the pair by hand (see config/reviewView).
|
|
onClick={() => setReviewDefaultDiffType(opt.value)}
|
|
className={`w-full flex items-start gap-3 p-3 rounded-lg border transition-colors text-left ${
|
|
defaultDiffType === opt.value
|
|
? 'border-primary bg-primary/5'
|
|
: 'border-border hover:border-muted-foreground/30 hover:bg-muted/50'
|
|
}`}
|
|
>
|
|
<div className={`mt-0.5 w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
|
|
defaultDiffType === opt.value ? 'border-primary' : 'border-muted-foreground/40'
|
|
}`}>
|
|
{defaultDiffType === opt.value && (
|
|
<div className="w-2 h-2 rounded-full bg-primary" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium">{opt.label}</div>
|
|
<div className="text-xs text-muted-foreground">{opt.description}</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isCompactTouchLayout = false }) => {
|
|
const diffStyle = useConfigValue('diffStyle');
|
|
const diffOverflow = useConfigValue('diffOverflow');
|
|
const diffIndicators = useConfigValue('diffIndicators');
|
|
const diffLineDiffType = useConfigValue('diffLineDiffType');
|
|
const diffShowLineNumbers = useConfigValue('diffShowLineNumbers');
|
|
const diffShowBackground = useConfigValue('diffShowBackground');
|
|
const diffLineBgIntensity = useConfigValue('diffLineBgIntensity');
|
|
const diffHideWhitespace = useConfigValue('diffHideWhitespace');
|
|
const editSuggestions = useConfigValue('editSuggestions');
|
|
const diffExpandUnchanged = useConfigValue('diffExpandUnchanged');
|
|
const diffFontFamily = useConfigValue('diffFontFamily');
|
|
const diffFontSize = useConfigValue('diffFontSize');
|
|
|
|
// Load font for the preview swatch
|
|
useEffect(() => {
|
|
if (diffFontFamily) loadDiffFont(diffFontFamily);
|
|
}, [diffFontFamily]);
|
|
|
|
return (
|
|
<>
|
|
{/* Experimental: edit code to author suggestions */}
|
|
<div className="space-y-3">
|
|
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Experimental
|
|
</div>
|
|
<ToggleSwitch
|
|
checked={editSuggestions}
|
|
onChange={(v) => configStore.set('editSuggestions', v)}
|
|
label="Edit Code to Suggest"
|
|
description="Edit a file in place in the all-files view; your net change becomes a suggestion comment. Files are never written from the browser. Uses an experimental upstream editor."
|
|
/>
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Font Family */}
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Code Font</div>
|
|
<div className="text-xs text-muted-foreground">Font family for diff code lines</div>
|
|
</div>
|
|
<select
|
|
value={diffFontFamily}
|
|
onChange={(e) => configStore.set('diffFontFamily', e.target.value)}
|
|
className="w-full px-3 py-1.5 text-sm rounded-md bg-muted/50 border border-border text-foreground"
|
|
style={diffFontFamily ? { fontFamily: `'${diffFontFamily}', monospace` } : undefined}
|
|
>
|
|
{DIFF_FONT_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
{diffFontFamily && (
|
|
<div
|
|
className="text-xs text-muted-foreground px-1 py-1 rounded bg-muted/30 font-mono"
|
|
style={{ fontFamily: `'${diffFontFamily}', monospace` }}
|
|
>
|
|
Preview: const x = fn(42);
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Font Size */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">Code Font Size</div>
|
|
<div className="text-xs text-muted-foreground">Font size for diff code lines</div>
|
|
</div>
|
|
<div className="text-xs tabular-nums text-muted-foreground min-w-[4ch] text-right">
|
|
{diffFontSize || 'Auto'}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="range"
|
|
min={8}
|
|
max={24}
|
|
step={1}
|
|
value={diffFontSize ? parseInt(diffFontSize) : 13}
|
|
onChange={(e) => configStore.set('diffFontSize', `${e.target.value}px`)}
|
|
className="flex-1 h-1.5 accent-primary cursor-pointer"
|
|
/>
|
|
{diffFontSize && (
|
|
<button
|
|
onClick={() => configStore.set('diffFontSize', '')}
|
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Reset
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Diff Style. The compact touch shell renders a session-only unified
|
|
diff, so the control there would look dead while quietly rewriting
|
|
the DESKTOP preference. Say what the phone is doing instead. */}
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Diff Style</div>
|
|
<div className="text-xs text-muted-foreground">Side-by-side or inline diff view</div>
|
|
{isCompactTouchLayout && (
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
This layout shows unified diffs for the session; your desktop
|
|
preference is unchanged.
|
|
</div>
|
|
)}
|
|
</div>
|
|
{!isCompactTouchLayout && (
|
|
<SegmentedControl options={DIFF_STYLE_OPTIONS} value={diffStyle} onChange={(v) => configStore.set('diffStyle', v)} />
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Line Overflow */}
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Line Overflow</div>
|
|
<div className="text-xs text-muted-foreground">How to handle long lines in diffs</div>
|
|
</div>
|
|
<SegmentedControl options={OVERFLOW_OPTIONS} value={diffOverflow} onChange={(v) => configStore.set('diffOverflow', v)} />
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Change Indicators */}
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Change Indicators</div>
|
|
<div className="text-xs text-muted-foreground">Style of +/- markers in the gutter</div>
|
|
</div>
|
|
<SegmentedControl options={INDICATOR_OPTIONS} value={diffIndicators} onChange={(v) => configStore.set('diffIndicators', v)} />
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Inline Diff Granularity */}
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Inline Diff Granularity</div>
|
|
<div className="text-xs text-muted-foreground">Highlight granularity for inline changes</div>
|
|
</div>
|
|
<SegmentedControl options={LINE_DIFF_OPTIONS} value={diffLineDiffType} onChange={(v) => configStore.set('diffLineDiffType', v)} />
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Show Line Numbers */}
|
|
<ToggleSwitch
|
|
checked={diffShowLineNumbers}
|
|
onChange={(v) => configStore.set('diffShowLineNumbers', v)}
|
|
label="Show Line Numbers"
|
|
/>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Show Diff Background */}
|
|
<ToggleSwitch
|
|
checked={diffShowBackground}
|
|
onChange={(v) => configStore.set('diffShowBackground', v)}
|
|
label="Show Diff Background"
|
|
description="Colored backgrounds on added/deleted lines"
|
|
/>
|
|
|
|
{/* Line Background Intensity */}
|
|
{diffShowBackground && (
|
|
<div className="space-y-2 pl-4">
|
|
<div>
|
|
<div className="text-sm font-medium">Line Background Intensity</div>
|
|
<div className="text-xs text-muted-foreground">How prominent the colored line backgrounds appear</div>
|
|
</div>
|
|
<SegmentedControl options={LINE_BG_INTENSITY_OPTIONS} value={diffLineBgIntensity} onChange={(v) => configStore.set('diffLineBgIntensity', v)} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Expand Unchanged Regions */}
|
|
<ToggleSwitch
|
|
checked={diffExpandUnchanged}
|
|
onChange={(v) => configStore.set('diffExpandUnchanged', v)}
|
|
label="Expand Unchanged Regions"
|
|
description="Show full file content around changes by default"
|
|
/>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Hide Whitespace */}
|
|
<ToggleSwitch
|
|
checked={diffHideWhitespace}
|
|
onChange={(v) => configStore.set('diffHideWhitespace', v)}
|
|
label="Hide Whitespace"
|
|
description="Ignore whitespace-only changes in diffs"
|
|
/>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
|
|
</>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Stored label config — serialized to JSON in the config store.
|
|
* `blocking` controls whether the blocking/non-blocking toggle
|
|
* appears in the picker when this label is selected.
|
|
*/
|
|
interface CCLabelConfig {
|
|
label: string;
|
|
display: string;
|
|
blocking: boolean; // true = show toggle in picker, false = no decoration
|
|
}
|
|
|
|
const DEFAULT_CC_LABELS: CCLabelConfig[] = [
|
|
{ label: 'suggestion', display: 'suggestion', blocking: true },
|
|
{ label: 'nitpick', display: 'nit', blocking: false },
|
|
{ label: 'question', display: 'question', blocking: true },
|
|
{ label: 'issue', display: 'issue', blocking: true },
|
|
{ label: 'praise', display: 'praise', blocking: false },
|
|
{ label: 'thought', display: 'thought', blocking: false },
|
|
{ label: 'note', display: 'note', blocking: false },
|
|
{ label: 'todo', display: 'todo', blocking: true },
|
|
{ label: 'chore', display: 'chore', blocking: true },
|
|
];
|
|
|
|
function parseCCLabels(json: string | null): CCLabelConfig[] {
|
|
if (!json) return DEFAULT_CC_LABELS;
|
|
try {
|
|
const parsed = JSON.parse(json);
|
|
if (!Array.isArray(parsed)) return DEFAULT_CC_LABELS;
|
|
return parsed.map((l: Record<string, unknown>) => ({
|
|
label: (l.label as string) || 'custom',
|
|
display: (l.display as string) || (l.label as string) || 'custom',
|
|
blocking: l.blocking === true || l.blocking === 'true',
|
|
}));
|
|
} catch {
|
|
return DEFAULT_CC_LABELS;
|
|
}
|
|
}
|
|
|
|
const CommentsTab: React.FC = () => {
|
|
const conventionalComments = useConfigValue('conventionalComments');
|
|
const labelsJson = useConfigValue('conventionalLabels');
|
|
const [labels, setLabels] = useState(() => parseCCLabels(labelsJson));
|
|
|
|
const isDefault = labelsJson === null;
|
|
|
|
const save = (next: CCLabelConfig[]) => {
|
|
setLabels(next);
|
|
configStore.set('conventionalLabels', JSON.stringify(next));
|
|
};
|
|
|
|
const updateLabel = (index: number, updates: Partial<CCLabelConfig>) => {
|
|
const next = [...labels];
|
|
next[index] = { ...next[index], ...updates };
|
|
save(next);
|
|
};
|
|
|
|
const removeLabel = (index: number) => {
|
|
save(labels.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const addLabel = () => {
|
|
const existing = new Set(labels.map(l => l.label));
|
|
let slug = 'custom';
|
|
let n = 2;
|
|
while (existing.has(slug)) { slug = `custom-${n++}`; }
|
|
save([...labels, { label: slug, display: slug, blocking: false }]);
|
|
};
|
|
|
|
const resetToDefaults = () => {
|
|
setLabels(DEFAULT_CC_LABELS);
|
|
configStore.set('conventionalLabels', null);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<ToggleSwitch
|
|
checked={conventionalComments}
|
|
onChange={(v) => configStore.set('conventionalComments', v)}
|
|
label="Conventional Comments"
|
|
description="Add structured labels to review comments"
|
|
/>
|
|
|
|
<div className={`space-y-4 transition-opacity ${conventionalComments ? '' : 'opacity-40 pointer-events-none'}`}>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* How it works */}
|
|
<div className="space-y-3">
|
|
<div>
|
|
<div className="text-sm font-medium">How it works</div>
|
|
<div className="text-xs text-muted-foreground leading-relaxed mt-1">
|
|
When enabled, a label picker appears above the comment input when annotating code.
|
|
Labels classify your feedback intent, making it clear whether a comment is a blocking issue,
|
|
a trivial nitpick, or praise.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-xs text-muted-foreground leading-relaxed">
|
|
Based on the{' '}
|
|
<a
|
|
href="https://conventionalcomments.org"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline"
|
|
>
|
|
Conventional Comments
|
|
</a>
|
|
{' '}spec. Comments are exported as plain text, readable on GitHub, and parseable by tooling.
|
|
</div>
|
|
|
|
{/* Example output */}
|
|
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-1.5">
|
|
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Example output</div>
|
|
<div className="font-mono text-[11px] text-foreground/80 leading-relaxed">
|
|
<span className="font-bold">issue</span> <span className="text-muted-foreground">(blocking)</span>: This will throw if user is null — the guard was removed in the refactor.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Label editor */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">Labels</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Customize labels and their default severity
|
|
</div>
|
|
</div>
|
|
{!isDefault && (
|
|
<button
|
|
onClick={resetToDefaults}
|
|
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Reset to defaults
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Column headers */}
|
|
<div className="flex items-end gap-2 px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
|
|
<span className="flex-1">Label</span>
|
|
<span className="w-20 text-center">Blocking decorator</span>
|
|
<span className="w-6" />
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
{labels.map((label, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-center gap-2 p-2 rounded-lg bg-muted/40"
|
|
>
|
|
<input
|
|
type="text"
|
|
value={label.display}
|
|
onChange={(e) => {
|
|
const display = e.target.value;
|
|
updateLabel(index, { display, label: display });
|
|
}}
|
|
className="flex-1 px-2 py-1 bg-background/80 rounded text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50 min-w-0"
|
|
/>
|
|
<div className="w-20 flex justify-center">
|
|
<button
|
|
role="switch"
|
|
aria-checked={label.blocking}
|
|
onClick={() => updateLabel(index, { blocking: !label.blocking })}
|
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
label.blocking ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
label.blocking ? 'translate-x-4.5' : 'translate-x-0.5'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => removeLabel(index)}
|
|
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
|
|
title="Remove label"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{labels.length < 12 && (
|
|
<button
|
|
onClick={addLabel}
|
|
className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed border-border rounded-lg hover:border-foreground/30 transition-colors"
|
|
>
|
|
+ Add label
|
|
</button>
|
|
)}
|
|
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
|
|
const webmcpTools = useWebMcpToolsEnabled();
|
|
const [showDialog, setShowDialog] = useState(false);
|
|
const settingsWasOpenRef = useRef(false);
|
|
const [themePreview, setThemePreview] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const wasOpen = settingsWasOpenRef.current;
|
|
settingsWasOpenRef.current = showDialog;
|
|
if (
|
|
wasOpen
|
|
&& !showDialog
|
|
&& !themePreview
|
|
&& mode !== 'review'
|
|
&& configStore.get('vimModeEnabled')
|
|
) {
|
|
requestVimDocumentFocus();
|
|
}
|
|
}, [mode, showDialog, themePreview]);
|
|
|
|
useEffect(() => {
|
|
if (!themePreview) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setThemePreview(false); setShowDialog(true); }
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [themePreview]);
|
|
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
|
|
const gridEnabled = useConfigValue('gridEnabled');
|
|
const vimModeEnabled = useConfigValue('vimModeEnabled');
|
|
const vimHudEnabled = useConfigValue('vimHudEnabled');
|
|
const vimHudKeyPanelEnabled = useConfigValue('vimHudKeyPanelEnabled');
|
|
const agentTerminalSide = useConfigValue('agentTerminalSide');
|
|
const [identity, setIdentity] = useState('');
|
|
const [obsidian, setObsidian] = useState<ObsidianSettings>({
|
|
enabled: false,
|
|
vaultPath: '',
|
|
folder: 'plannotator',
|
|
filenameSeparator: 'space',
|
|
autoSave: false,
|
|
vaultBrowserEnabled: false,
|
|
});
|
|
const [detectedVaults, setDetectedVaults] = useState<string[]>([]);
|
|
const [vaultsLoading, setVaultsLoading] = useState(false);
|
|
const [bear, setBear] = useState<BearSettings>({ enabled: false, customTags: '', tagPosition: 'append', autoSave: false });
|
|
const [octarine, setOctarine] = useState<OctarineSettings>({ enabled: false, workspace: '', folder: 'plannotator', autoSave: false });
|
|
// The agent switch default depends on the surface: plan approval hands off to
|
|
// the build agent, review feedback stays on the current agent.
|
|
const agentSurface: AgentSwitchSurface = mode === 'review' ? 'review' : 'plan';
|
|
const [agent, setAgent] = useState<AgentSwitchSettings>(() => getAgentSwitchDefaults(agentSurface));
|
|
const [planSave, setPlanSave] = useState<PlanSaveSettings>({ enabled: true, customPath: null });
|
|
const [uiPrefs, setUiPrefs] = useState<UIPreferences>({ tocEnabled: true, stickyActionsEnabled: true, planWidth: 'compact' });
|
|
const [permissionMode, setPermissionMode] = useState<PermissionMode>('bypassPermissions');
|
|
const [agentWarning, setAgentWarning] = useState<string | null>(null);
|
|
const [autoCloseDelay, setAutoCloseDelayState] = useState<AutoCloseDelay>('off');
|
|
const [defaultNotesApp, setDefaultNotesApp] = useState<DefaultNotesApp>('ask');
|
|
const [quickLabelsState, setQuickLabelsState] = useState<QuickLabel[]>([]);
|
|
const [editingTipIndex, setEditingTipIndex] = useState<number | null>(null);
|
|
const [editingTipValue, setEditingTipValue] = useState('');
|
|
const [aiProvider, setAiProvider] = useState<string | null>(null);
|
|
const [fileBrowserSettings, setFileBrowserSettings] = useState<FileBrowserSettings>({ enabled: false, directories: [] });
|
|
const [newDirPath, setNewDirPath] = useState('');
|
|
|
|
// Fetch available agents for OpenCode
|
|
const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin ?? null, agentSurface);
|
|
|
|
const mainTabs = useMemo(() => {
|
|
const t: { id: SettingsTab; label: string }[] = [{ id: 'general', label: 'General' }];
|
|
t.push({ id: 'theme', label: 'Theme' });
|
|
if (mode === 'plan') {
|
|
t.push({ id: 'display', label: 'Display' });
|
|
t.push({ id: 'saving', label: 'Saving' });
|
|
t.push({ id: 'labels', label: 'Labels' });
|
|
}
|
|
if (mode === 'review') {
|
|
t.push({ id: 'git', label: 'Git' });
|
|
t.push({ id: 'display', label: 'Editor' });
|
|
t.push({ id: 'analysis', label: 'Analysis' });
|
|
t.push({ id: 'comments', label: 'Comments' });
|
|
if (aiProviders.length > 0) {
|
|
t.push({ id: 'ai', label: 'AI' });
|
|
}
|
|
}
|
|
if (mode !== 'review') {
|
|
t.push({ id: 'vim', label: 'Vim' });
|
|
}
|
|
t.push({ id: 'shortcuts', label: 'Shortcuts' });
|
|
if (mode === 'plan') {
|
|
t.push({ id: 'hooks', label: 'Hooks' });
|
|
}
|
|
return t;
|
|
}, [mode, aiProviders.length]);
|
|
|
|
const integrationTabs: { id: SettingsTab; label: string }[] = [
|
|
{ id: 'files', label: 'Files' },
|
|
...(mode === 'plan'
|
|
? [{ id: 'obsidian' as SettingsTab, label: 'Obsidian' }, { id: 'bear' as SettingsTab, label: 'Bear' }, { id: 'octarine' as SettingsTab, label: 'Octarine' }]
|
|
: []),
|
|
];
|
|
const obsidianDefaultSaveAvailable = obsidian.enabled && getEffectiveVaultPath(obsidian).trim().length > 0;
|
|
const bearDefaultSaveAvailable = bear.enabled;
|
|
const octarineDefaultSaveAvailable = octarine.enabled && octarine.workspace.trim().length > 0;
|
|
|
|
// Sync external open state
|
|
useEffect(() => {
|
|
if (externalOpen) {
|
|
setShowDialog(true);
|
|
onExternalClose?.();
|
|
}
|
|
}, [externalOpen, onExternalClose]);
|
|
|
|
useEffect(() => {
|
|
if (showDialog) {
|
|
setIdentity(getIdentity())
|
|
setObsidian(getObsidianSettings());
|
|
setBear(getBearSettings());
|
|
setOctarine(getOctarineSettings());
|
|
setAgent(getAgentSwitchSettings(agentSurface));
|
|
setPlanSave(getPlanSaveSettings());
|
|
setUiPrefs(getUIPreferences());
|
|
setPermissionMode(getPermissionModeSettings().mode);
|
|
setAutoCloseDelayState(getAutoCloseDelay());
|
|
setDefaultNotesApp(getDefaultNotesApp());
|
|
setQuickLabelsState(getQuickLabels());
|
|
const aiSettings = getAIProviderSettings();
|
|
setAiProvider(resolveAIProviderSelection({ providers: aiProviders, origin, settings: aiSettings }).providerId);
|
|
setFileBrowserSettings(getFileBrowserSettings());
|
|
|
|
// Validate agent setting when dialog opens
|
|
if (origin === 'opencode') {
|
|
setAgentWarning(getAgentWarning());
|
|
}
|
|
}
|
|
}, [showDialog, availableAgents, origin, getAgentWarning, aiProviders.length]);
|
|
|
|
useEffect(() => {
|
|
if (!showDialog) return;
|
|
|
|
const defaultSaveAvailable =
|
|
defaultNotesApp === 'ask' ||
|
|
defaultNotesApp === 'download' ||
|
|
(defaultNotesApp === 'obsidian' && obsidianDefaultSaveAvailable) ||
|
|
(defaultNotesApp === 'bear' && bearDefaultSaveAvailable) ||
|
|
(defaultNotesApp === 'octarine' && octarineDefaultSaveAvailable);
|
|
|
|
if (!defaultSaveAvailable) {
|
|
setDefaultNotesApp('ask');
|
|
saveDefaultNotesApp('ask');
|
|
}
|
|
}, [
|
|
showDialog,
|
|
defaultNotesApp,
|
|
obsidianDefaultSaveAvailable,
|
|
bearDefaultSaveAvailable,
|
|
octarineDefaultSaveAvailable,
|
|
]);
|
|
|
|
// Fetch detected vaults when Obsidian is enabled
|
|
useEffect(() => {
|
|
if (obsidian.enabled && detectedVaults.length === 0 && !vaultsLoading) {
|
|
setVaultsLoading(true);
|
|
const detect = onDetectObsidianVaults
|
|
?? (() => fetch('/api/obsidian/vaults').then(res => res.json()).then((data: { vaults: string[] }) => data.vaults || []));
|
|
detect()
|
|
.then((vaults: string[]) => {
|
|
setDetectedVaults(vaults || []);
|
|
// Auto-select first vault if none set
|
|
if (vaults?.length > 0 && !obsidian.vaultPath) {
|
|
handleObsidianChange({ vaultPath: vaults[0] });
|
|
}
|
|
})
|
|
.catch(() => setDetectedVaults([]))
|
|
.finally(() => setVaultsLoading(false));
|
|
}
|
|
}, [obsidian.enabled]);
|
|
|
|
const handleFileBrowserChange = (updates: Partial<FileBrowserSettings>) => {
|
|
const newSettings = { ...fileBrowserSettings, ...updates };
|
|
setFileBrowserSettings(newSettings);
|
|
saveFileBrowserSettings(newSettings);
|
|
if (onUIPreferencesChange) onUIPreferencesChange({ ...uiPrefs });
|
|
};
|
|
|
|
const addDirectory = () => {
|
|
const trimmed = newDirPath.trim();
|
|
if (trimmed && !fileBrowserSettings.directories.includes(trimmed)) {
|
|
handleFileBrowserChange({
|
|
directories: [...fileBrowserSettings.directories, trimmed],
|
|
});
|
|
}
|
|
setNewDirPath('');
|
|
};
|
|
|
|
const handleObsidianChange = (updates: Partial<ObsidianSettings>) => {
|
|
const newSettings = { ...obsidian, ...updates };
|
|
setObsidian(newSettings);
|
|
saveObsidianSettings(newSettings);
|
|
};
|
|
|
|
const handleBearChange = (updates: Partial<BearSettings>) => {
|
|
const newSettings = { ...bear, ...updates };
|
|
setBear(newSettings);
|
|
saveBearSettings(newSettings);
|
|
};
|
|
|
|
const handleOctarineChange = (updates: Partial<OctarineSettings>) => {
|
|
const newSettings = { ...octarine, ...updates };
|
|
setOctarine(newSettings);
|
|
saveOctarineSettings(newSettings);
|
|
};
|
|
|
|
const handleAgentChange = (switchTo: AgentSwitchSettings['switchTo'], customName?: string) => {
|
|
const newSettings = { switchTo, customName: customName ?? agent.customName };
|
|
setAgent(newSettings);
|
|
saveAgentSwitchSettings(newSettings);
|
|
};
|
|
|
|
const handlePlanSaveChange = (updates: Partial<PlanSaveSettings>) => {
|
|
const newSettings = { ...planSave, ...updates };
|
|
setPlanSave(newSettings);
|
|
savePlanSaveSettings(newSettings);
|
|
};
|
|
|
|
const handleUIPrefsChange = (updates: Partial<UIPreferences>) => {
|
|
const newPrefs = { ...uiPrefs, ...updates };
|
|
setUiPrefs(newPrefs);
|
|
saveUIPreferences(newPrefs);
|
|
onUIPreferencesChange?.(newPrefs);
|
|
};
|
|
|
|
const handlePermissionModeChange = (mode: PermissionMode) => {
|
|
setPermissionMode(mode);
|
|
savePermissionModeSettings(mode);
|
|
};
|
|
|
|
const handleDefaultNotesAppChange = (app: DefaultNotesApp) => {
|
|
setDefaultNotesApp(app);
|
|
saveDefaultNotesApp(app);
|
|
};
|
|
|
|
// Server write-back is handled automatically by configStore.set() (debounced POST /api/config)
|
|
|
|
const handleRegenerateIdentity = () => {
|
|
const oldIdentity = identity;
|
|
const newIdentity = regenerateIdentity();
|
|
setIdentity(newIdentity);
|
|
onIdentityChange?.(oldIdentity, newIdentity);
|
|
};
|
|
|
|
const handleIdentitySave = (newName: string) => {
|
|
const trimmed = newName.trim();
|
|
if (!trimmed || trimmed === identity) return;
|
|
const oldIdentity = identity;
|
|
const saved = setCustomIdentity(trimmed);
|
|
setIdentity(saved);
|
|
onIdentityChange?.(oldIdentity, saved);
|
|
};
|
|
|
|
const handleUseGitName = () => {
|
|
if (!gitUser) return;
|
|
handleIdentitySave(gitUser);
|
|
};
|
|
|
|
// When a host owns identity (e.g. a logged-in user whose author is server-stamped),
|
|
// hide the rename/regenerate controls so a local name can't diverge from it.
|
|
// Default (Plannotator cookie identity) is editable, so this is true and the
|
|
// controls render exactly as before.
|
|
const identityEditable = isIdentityEditable();
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
onClick={() => setShowDialog(true)}
|
|
className="relative p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
title="Settings"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</button>
|
|
|
|
{showDialog && !themePreview && createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
|
|
onClick={() => setShowDialog(false)}
|
|
>
|
|
<div
|
|
className="bg-card border border-border rounded-xl w-full max-w-2xl max-h-[85vh] flex flex-col shadow-2xl relative overflow-hidden"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="plannotator-settings-title"
|
|
onClick={e => e.stopPropagation()}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setShowDialog(false);
|
|
}}
|
|
>
|
|
{taterMode && <TaterSpritePullup />}
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<h3 id="plannotator-settings-title" className="font-semibold text-sm">Settings</h3>
|
|
<button
|
|
type="button"
|
|
aria-label="Close settings"
|
|
onClick={() => setShowDialog(false)}
|
|
className="p-1.5 rounded-md bg-muted hover:bg-muted/80 text-foreground transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-col md:flex-row md:min-h-[420px] flex-1 min-h-0 overflow-hidden">
|
|
{/* Mobile: horizontal tab bar */}
|
|
<nav className="md:hidden flex overflow-x-auto border-b border-border px-2 py-1.5 gap-1 flex-shrink-0">
|
|
{[...mainTabs, ...integrationTabs].map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`px-3 py-1.5 rounded text-xs whitespace-nowrap transition-colors flex items-center gap-1.5 ${
|
|
activeTab === tab.id
|
|
? 'bg-primary/10 text-primary font-medium'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{/* Desktop: sidebar */}
|
|
<nav className="hidden md:block w-40 border-r border-border p-2 flex-shrink-0">
|
|
<div className="space-y-0.5">
|
|
{mainTabs.map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors flex items-center justify-between ${
|
|
activeTab === tab.id
|
|
? 'bg-primary/10 text-primary font-medium'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{integrationTabs.length > 0 && (
|
|
<>
|
|
<div className="mx-2 my-2 border-t border-border/50" />
|
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
|
|
Integrations
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
{integrationTabs.map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
|
|
activeTab === tab.id
|
|
? 'bg-primary/10 text-primary font-medium'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</nav>
|
|
|
|
{/* Content — scrollable */}
|
|
<OverlayScrollArea className="flex-1 min-h-0">
|
|
<div className="p-4 space-y-4">
|
|
|
|
{/* === GENERAL TAB === */}
|
|
{activeTab === 'general' && (
|
|
<>
|
|
{/* Identity */}
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium">Your Identity</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Used when sharing annotations with others
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{identityEditable ? (
|
|
<input
|
|
key={identity}
|
|
type="text"
|
|
defaultValue={identity}
|
|
onBlur={(e) => handleIdentitySave(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
handleIdentitySave((e.target as HTMLInputElement).value);
|
|
(e.target as HTMLInputElement).blur();
|
|
}
|
|
}}
|
|
className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono truncate border border-transparent focus:border-primary/50 focus:outline-none transition-colors"
|
|
placeholder="Enter your name..."
|
|
/>
|
|
) : (
|
|
<div className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono truncate border border-transparent">
|
|
{identity}
|
|
</div>
|
|
)}
|
|
{identityEditable && gitUser && (
|
|
<button
|
|
onClick={handleUseGitName}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
className="p-2 rounded-lg bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground transition-colors"
|
|
title={`Use git identity: ${gitUser}`}
|
|
>
|
|
<GitUser className="w-5 h-5" />
|
|
</button>
|
|
)}
|
|
{identityEditable && (
|
|
<button
|
|
onClick={handleRegenerateIdentity}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
className="p-2 rounded-lg bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground transition-colors"
|
|
title="Regenerate random identity"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Agent tools (WebMCP). Shown only when the browser
|
|
exposes document.modelContext; off unregisters the
|
|
tools for this browser. Cookie-only and idle at its
|
|
default: no cookie exists until the user opts out. */}
|
|
{webmcpAvailable && (
|
|
<>
|
|
<div className="border-t border-border" />
|
|
<div className="flex items-center justify-between" data-webmcp-setting="true">
|
|
<div>
|
|
<div className="text-sm font-medium">Agent tools</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Let your browser's agent read this document and leave comments. Approving and sending feedback stay yours.
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={webmcpTools}
|
|
aria-label="Agent tools"
|
|
onClick={() => setWebMcpToolsEnabled(!webmcpTools)}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
webmcpTools ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
webmcpTools ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Permission Mode (Claude Code only) */}
|
|
{origin === 'claude-code' && mode === 'plan' && (
|
|
<>
|
|
<div className="border-t border-border" />
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Permission Mode</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Automation level after plan approval
|
|
</div>
|
|
</div>
|
|
<select
|
|
value={permissionMode}
|
|
onChange={(e) => handlePermissionModeChange(e.target.value as PermissionMode)}
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
|
|
>
|
|
{PERMISSION_MODE_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
{PERMISSION_MODE_OPTIONS.find(o => o.value === permissionMode)?.description}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Agent Switching (OpenCode only) */}
|
|
{origin === 'opencode' && (
|
|
<>
|
|
<div className="border-t border-border" />
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Agent Switching</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Which agent to switch to after plan approval
|
|
</div>
|
|
</div>
|
|
|
|
{agentWarning && (
|
|
<div className="flex items-start gap-2 p-2 bg-amber-500/10 border border-amber-500/30 rounded-lg text-xs text-amber-600 dark:text-amber-400">
|
|
<svg className="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<span>{agentWarning}</span>
|
|
</div>
|
|
)}
|
|
|
|
<select
|
|
value={agent.switchTo}
|
|
onChange={(e) => {
|
|
handleAgentChange(e.target.value);
|
|
setAgentWarning(null);
|
|
}}
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
|
|
>
|
|
{availableAgents.length > 0 ? (
|
|
<>
|
|
{agent.switchTo !== 'custom' &&
|
|
agent.switchTo !== 'disabled' &&
|
|
!availableAgents.some(a => a.id.toLowerCase() === agent.switchTo.toLowerCase()) && (
|
|
<option value={agent.switchTo} disabled>
|
|
{agent.switchTo} (not found)
|
|
</option>
|
|
)}
|
|
{availableAgents.map((a) => (
|
|
<option key={a.id} value={a.id}>
|
|
{a.name}
|
|
</option>
|
|
))}
|
|
<option value="custom">Custom</option>
|
|
<option value="disabled">Disabled</option>
|
|
</>
|
|
) : (
|
|
AGENT_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))
|
|
)}
|
|
</select>
|
|
{agent.switchTo === 'custom' && (
|
|
<input
|
|
type="text"
|
|
value={agent.customName || ''}
|
|
onChange={(e) => {
|
|
const customName = e.target.value;
|
|
handleAgentChange('custom', customName);
|
|
if (customName && availableAgents.length > 0) {
|
|
if (!validateAgent(customName)) {
|
|
setAgentWarning(`Agent "${customName}" not found in OpenCode. It may cause errors.`);
|
|
} else {
|
|
setAgentWarning(null);
|
|
}
|
|
} else {
|
|
setAgentWarning(null);
|
|
}
|
|
}}
|
|
placeholder="Enter agent name..."
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 placeholder:text-muted-foreground/50"
|
|
/>
|
|
)}
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
{agent.switchTo === 'custom' && agent.customName
|
|
? `Switch to "${agent.customName}" agent after approval`
|
|
: agent.switchTo === 'disabled'
|
|
? 'Stay on current agent after approval'
|
|
: `Switch to ${agent.switchTo} agent after approval`}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Agent TUI position. The same control lives in the
|
|
terminal's own Display popover, but that one is
|
|
unreachable once the position is Hidden — this is the
|
|
way back. Both write the same config value. Gated on the
|
|
terminal actually being available in this session, so a
|
|
remote or runtime-less annotate never offers a dead
|
|
control. */}
|
|
{mode === 'annotate' && agentTerminalAvailable && (
|
|
<>
|
|
<div className="border-t border-border" />
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Agent TUI Position</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Which side the agent terminal docks on, or Hidden to keep it out of the layout
|
|
</div>
|
|
</div>
|
|
<SegmentedControl
|
|
options={AGENT_TERMINAL_SIDE_OPTIONS}
|
|
value={agentTerminalSide}
|
|
onChange={(v) => configStore.set('agentTerminalSide', v)}
|
|
/>
|
|
{agentTerminalSide === 'hidden' && (
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
You can still open it for this session from the sidebar rail.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Auto-close Tab */}
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium">Auto-close Tab</div>
|
|
<select
|
|
value={autoCloseDelay}
|
|
onChange={(e) => {
|
|
const next = e.target.value as AutoCloseDelay;
|
|
setAutoCloseDelayState(next);
|
|
setAutoCloseDelay(next);
|
|
}}
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
|
|
>
|
|
{AUTO_CLOSE_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
{AUTO_CLOSE_OPTIONS.find(o => o.value === autoCloseDelay)?.description}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* === THEME TAB === */}
|
|
{activeTab === 'theme' && <ThemeTab onPreview={() => { setShowDialog(false); setThemePreview(true); }} />}
|
|
|
|
{/* === GIT TAB === */}
|
|
{activeTab === 'git' && mode === 'review' && (
|
|
<GitTab sinceBaseUnavailable={sinceBaseUnavailable} />
|
|
)}
|
|
|
|
{/* === DISPLAY TAB === */}
|
|
{activeTab === 'display' && mode === 'review' && (
|
|
<ReviewDisplayTab isCompactTouchLayout={isCompactTouchLayout} />
|
|
)}
|
|
|
|
{activeTab === 'analysis' && mode === 'review' && (
|
|
<ReviewAnalysisTab />
|
|
)}
|
|
|
|
{activeTab === 'display' && mode !== 'review' && (
|
|
<>
|
|
{/* Auto-open Sidebar */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">Auto-open Sidebar</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Open sidebar with Table of Contents on load
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={uiPrefs.tocEnabled}
|
|
onClick={() => handleUIPrefsChange({ tocEnabled: !uiPrefs.tocEnabled })}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
uiPrefs.tocEnabled ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
uiPrefs.tocEnabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Sticky Actions */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">Sticky Actions</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Keep action buttons visible while scrolling
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={uiPrefs.stickyActionsEnabled}
|
|
onClick={() => handleUIPrefsChange({ stickyActionsEnabled: !uiPrefs.stickyActionsEnabled })}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
uiPrefs.stickyActionsEnabled ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
uiPrefs.stickyActionsEnabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Grid Background */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">Grid Background</div>
|
|
<div className="text-xs text-muted-foreground">Show the plan as a floating card on a grid</div>
|
|
</div>
|
|
<button role="switch" aria-checked={gridEnabled}
|
|
onClick={() => configStore.set('gridEnabled', !gridEnabled)}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${gridEnabled ? 'bg-primary' : 'bg-muted'}`}>
|
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${gridEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Plan Width */}
|
|
<div className="space-y-3">
|
|
<div>
|
|
<div className="text-sm font-medium flex items-center gap-2">Plan Width</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Maximum width of the plan card
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 bg-muted/50 rounded-lg p-0.5">
|
|
{PLAN_WIDTH_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.id}
|
|
onClick={() => handleUIPrefsChange({ planWidth: opt.id })}
|
|
className={`flex-1 px-3 py-1.5 text-xs rounded-md transition-colors ${
|
|
uiPrefs.planWidth === opt.id
|
|
? 'bg-background text-foreground shadow-sm font-medium'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Abstract layout preview — exaggerated proportions for visual clarity */}
|
|
{(() => {
|
|
const active = PLAN_WIDTH_OPTIONS.find(o => o.id === uiPrefs.planWidth) ?? PLAN_WIDTH_OPTIONS[0];
|
|
// Exaggerated proportions so the width difference is visually obvious in the small preview
|
|
const sidebarPct = 14;
|
|
const panelPct = 14;
|
|
const cardPctMap: Record<PlanWidth, number> = { compact: 48, default: 70, wide: 94 };
|
|
const cardPct = cardPctMap[active.id];
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="rounded-lg border border-border/40 bg-muted/20 px-2 py-3 overflow-hidden">
|
|
{/* Simulated header bar */}
|
|
<div className="flex items-center justify-between mb-2 px-1">
|
|
<div className="h-0.5 w-8 rounded-full bg-foreground/15" />
|
|
<div className="flex gap-1">
|
|
<div className="h-1 w-1 rounded-full bg-foreground/15" />
|
|
<div className="h-1 w-1 rounded-full bg-foreground/15" />
|
|
<div className="h-1 w-1 rounded-full bg-foreground/15" />
|
|
</div>
|
|
</div>
|
|
<div className="border-t border-foreground/5 mb-2" />
|
|
{/* Three-column layout */}
|
|
<div className="flex gap-1 items-stretch" style={{ minHeight: 64 }}>
|
|
{/* Sidebar */}
|
|
<div className="flex-shrink-0 space-y-1 pt-0.5 opacity-30" style={{ width: `${sidebarPct}%` }}>
|
|
<div className="h-0.5 w-full rounded-full bg-foreground" />
|
|
<div className="h-0.5 w-3/4 rounded-full bg-foreground" />
|
|
<div className="h-0.5 w-1/2 rounded-full bg-foreground" />
|
|
<div className="h-0.5 w-2/3 rounded-full bg-foreground" />
|
|
<div className="h-0.5 w-1/2 rounded-full bg-foreground" />
|
|
</div>
|
|
{/* Plan card — width animates */}
|
|
<div className="flex-1 flex justify-center min-w-0">
|
|
<div
|
|
className="rounded border border-border/60 bg-card/50 p-1.5 space-y-1 transition-all duration-300 ease-out"
|
|
style={{ width: `${cardPct}%`, minWidth: 0 }}
|
|
>
|
|
{/* Heading */}
|
|
<div className="h-1 w-2/5 rounded-full bg-foreground/25" />
|
|
{/* Prose lines */}
|
|
<div className="space-y-[2px]">
|
|
<div className="h-[2px] w-full rounded-full bg-foreground/10" />
|
|
<div className="h-[2px] w-11/12 rounded-full bg-foreground/10" />
|
|
<div className="h-[2px] w-4/5 rounded-full bg-foreground/10" />
|
|
</div>
|
|
{/* Code block */}
|
|
<div className="rounded bg-muted/60 p-1 space-y-[2px]">
|
|
<div className="h-[2px] w-full rounded-full bg-primary/20" />
|
|
<div className="h-[2px] w-3/4 rounded-full bg-primary/20" />
|
|
<div className="h-[2px] w-5/6 rounded-full bg-primary/20" />
|
|
</div>
|
|
{/* More prose */}
|
|
<div className="space-y-[2px]">
|
|
<div className="h-[2px] w-full rounded-full bg-foreground/10" />
|
|
<div className="h-[2px] w-3/4 rounded-full bg-foreground/10" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Annotation panel */}
|
|
<div className="flex-shrink-0 space-y-1 pt-0.5 opacity-20" style={{ width: `${panelPct}%` }}>
|
|
<div className="rounded border border-foreground/20 p-0.5 space-y-[2px]">
|
|
<div className="h-[2px] w-full rounded-full bg-foreground" />
|
|
<div className="h-[2px] w-2/3 rounded-full bg-foreground" />
|
|
</div>
|
|
<div className="rounded border border-foreground/20 p-0.5 space-y-[2px]">
|
|
<div className="h-[2px] w-full rounded-full bg-foreground" />
|
|
<div className="h-[2px] w-1/2 rounded-full bg-foreground" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground/70 leading-snug">
|
|
{active.px}px — {active.hint}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Tater Mode */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm font-medium">Tater Mode</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={taterMode}
|
|
onClick={() => onTaterModeChange(!taterMode)}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
taterMode ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
taterMode ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* === SAVING TAB === */}
|
|
{activeTab === 'saving' && (
|
|
<>
|
|
{/* Plan Saving */}
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">Save Plans</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Auto-save plans to the default data directory
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={planSave.enabled}
|
|
onClick={() => handlePlanSaveChange({ enabled: !planSave.enabled })}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
planSave.enabled ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
planSave.enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{planSave.enabled && (
|
|
<div className="space-y-1.5 pl-0.5">
|
|
<label className="text-xs text-muted-foreground">Custom Path (optional)</label>
|
|
<input
|
|
type="text"
|
|
value={planSave.customPath || ''}
|
|
onChange={(e) => handlePlanSaveChange({ customPath: e.target.value || null })}
|
|
placeholder="Leave empty for default"
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
Leave empty to use default location
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Default Notes App */}
|
|
<div className="space-y-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Default Save Action</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Used for keyboard shortcut ({modKey}+S)
|
|
</div>
|
|
</div>
|
|
<select
|
|
value={defaultNotesApp}
|
|
onChange={(e) => handleDefaultNotesAppChange(e.target.value as DefaultNotesApp)}
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
|
|
>
|
|
<option value="ask">Ask each time</option>
|
|
<option value="download">Download Annotations</option>
|
|
{obsidianDefaultSaveAvailable && <option value="obsidian">Obsidian</option>}
|
|
{bearDefaultSaveAvailable && <option value="bear">Bear</option>}
|
|
{octarineDefaultSaveAvailable && <option value="octarine">Octarine</option>}
|
|
</select>
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
{defaultNotesApp === 'ask'
|
|
? 'Opens Export dialog with Notes tab'
|
|
: defaultNotesApp === 'download'
|
|
? `${modKey}+S downloads the annotations file`
|
|
: `${modKey}+S saves directly to ${{ obsidian: 'Obsidian', bear: 'Bear', octarine: 'Octarine' }[defaultNotesApp] ?? defaultNotesApp}`}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Integration links */}
|
|
<div className="space-y-2">
|
|
<div className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
|
|
Integrations
|
|
</div>
|
|
<button
|
|
onClick={() => setActiveTab('obsidian')}
|
|
className="w-full flex items-center justify-between px-3 py-2.5 bg-muted/50 hover:bg-muted rounded-lg text-sm transition-colors group"
|
|
>
|
|
<span className="text-foreground">Obsidian</span>
|
|
<span className="flex items-center gap-2">
|
|
<span className={`text-[10px] font-medium ${obsidian.enabled ? 'text-primary' : 'text-muted-foreground/50'}`}>
|
|
{obsidian.enabled ? 'Enabled' : 'Off'}
|
|
</span>
|
|
<svg className="w-3.5 h-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('bear')}
|
|
className="w-full flex items-center justify-between px-3 py-2.5 bg-muted/50 hover:bg-muted rounded-lg text-sm transition-colors group"
|
|
>
|
|
<span className="text-foreground">Bear Notes</span>
|
|
<span className="flex items-center gap-2">
|
|
<span className={`text-[10px] font-medium ${bear.enabled ? 'text-primary' : 'text-muted-foreground/50'}`}>
|
|
{bear.enabled ? 'Enabled' : 'Off'}
|
|
</span>
|
|
<svg className="w-3.5 h-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('octarine')}
|
|
className="w-full flex items-center justify-between px-3 py-2.5 bg-muted/50 hover:bg-muted rounded-lg text-sm transition-colors group"
|
|
>
|
|
<span className="text-foreground">Octarine</span>
|
|
<span className="flex items-center gap-2">
|
|
<span className={`text-[10px] font-medium ${octarine.enabled ? 'text-primary' : 'text-muted-foreground/50'}`}>
|
|
{octarine.enabled ? 'Enabled' : 'Off'}
|
|
</span>
|
|
<svg className="w-3.5 h-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* === LABELS TAB === */}
|
|
{activeTab === 'labels' && (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium flex items-center gap-2">Quick Labels</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Preset annotations for one-click feedback
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
resetQuickLabels();
|
|
setQuickLabelsState(DEFAULT_QUICK_LABELS);
|
|
}}
|
|
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Reset to defaults
|
|
</button>
|
|
</div>
|
|
|
|
<style>{`
|
|
@keyframes tip-slide-open {
|
|
from { opacity: 0; transform: translateY(-4px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
`}</style>
|
|
<div className="space-y-1.5">
|
|
{quickLabelsState.map((label, index) => {
|
|
const colors = getLabelColors(label.color);
|
|
const hasTip = !!label.tip;
|
|
const isEditingTip = editingTipIndex === index;
|
|
return (
|
|
<div key={index} className="rounded-lg overflow-hidden" style={{ backgroundColor: colors.bg }}>
|
|
{/* Main row */}
|
|
<div className="flex items-center gap-2 p-2">
|
|
<span className="text-sm flex-shrink-0">{label.emoji}</span>
|
|
<input
|
|
type="text"
|
|
value={label.text}
|
|
onChange={(e) => {
|
|
const updated = [...quickLabelsState];
|
|
updated[index] = {
|
|
...label,
|
|
text: e.target.value,
|
|
id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
|
|
};
|
|
setQuickLabelsState(updated);
|
|
saveQuickLabels(updated);
|
|
}}
|
|
className="flex-1 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
{/* Tip indicator button */}
|
|
<button
|
|
onClick={() => {
|
|
if (isEditingTip) {
|
|
setEditingTipIndex(null);
|
|
} else {
|
|
setEditingTipIndex(index);
|
|
setEditingTipValue(label.tip || '');
|
|
}
|
|
}}
|
|
className={`relative p-1 rounded transition-all flex-shrink-0 ${
|
|
hasTip
|
|
? 'bg-foreground/10 text-foreground/70 hover:text-foreground border border-foreground/15'
|
|
: 'text-muted-foreground/30 hover:text-muted-foreground/60 border border-dashed border-muted-foreground/20 hover:border-muted-foreground/40'
|
|
}`}
|
|
title={hasTip ? `Tip: ${label.tip}` : 'Add AI instruction tip'}
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
|
</svg>
|
|
{hasTip && (
|
|
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-foreground/50" />
|
|
)}
|
|
</button>
|
|
<select
|
|
value={label.color}
|
|
onChange={(e) => {
|
|
const updated = [...quickLabelsState];
|
|
updated[index] = { ...label, color: e.target.value };
|
|
setQuickLabelsState(updated);
|
|
saveQuickLabels(updated);
|
|
}}
|
|
className="px-1.5 py-1 bg-background/80 rounded text-[10px] focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
>
|
|
{Object.keys(LABEL_COLOR_MAP).map(c => (
|
|
<option key={c} value={c}>{c}</option>
|
|
))}
|
|
</select>
|
|
<span className="text-[10px] text-muted-foreground/50 font-mono w-8 text-center flex-shrink-0">
|
|
{index < 10 ? `${altKey}${isMac ? '' : '+'}${index === 9 ? '0' : index + 1}` : ''}
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
const updated = quickLabelsState.filter((_, i) => i !== index);
|
|
setQuickLabelsState(updated);
|
|
saveQuickLabels(updated);
|
|
if (editingTipIndex === index) setEditingTipIndex(null);
|
|
}}
|
|
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
|
|
title="Remove label"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
{/* Tip editor — slides open below the row */}
|
|
{isEditingTip && (
|
|
<div
|
|
className="flex items-center gap-1.5 px-2 pb-2 pt-0"
|
|
style={{ animation: 'tip-slide-open 0.15s ease-out' }}
|
|
>
|
|
<svg className="w-3 h-3 text-muted-foreground/40 flex-shrink-0 ml-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
value={editingTipValue}
|
|
onChange={(e) => setEditingTipValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
const updated = [...quickLabelsState];
|
|
updated[index] = { ...label, tip: editingTipValue || undefined };
|
|
setQuickLabelsState(updated);
|
|
saveQuickLabels(updated);
|
|
setEditingTipIndex(null);
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setEditingTipIndex(null);
|
|
}
|
|
}}
|
|
placeholder="AI instruction tip..."
|
|
className="flex-1 px-2 py-1 bg-background/60 rounded text-[10px] text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
autoFocus
|
|
onFocus={(e) => { e.target.setSelectionRange(0, 0); e.target.scrollLeft = 0; }}
|
|
/>
|
|
<button
|
|
onClick={() => {
|
|
const updated = [...quickLabelsState];
|
|
updated[index] = { ...label, tip: editingTipValue || undefined };
|
|
setQuickLabelsState(updated);
|
|
saveQuickLabels(updated);
|
|
setEditingTipIndex(null);
|
|
}}
|
|
className="p-1 rounded text-muted-foreground/50 hover:text-green-500 hover:bg-green-500/10 transition-colors flex-shrink-0"
|
|
title="Save tip"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{quickLabelsState.length < 12 && (
|
|
<button
|
|
onClick={() => {
|
|
const newLabel: QuickLabel = {
|
|
id: `custom-${Date.now()}`,
|
|
emoji: '📌',
|
|
text: 'New label',
|
|
color: 'blue',
|
|
};
|
|
const updated = [...quickLabelsState, newLabel];
|
|
setQuickLabelsState(updated);
|
|
saveQuickLabels(updated);
|
|
}}
|
|
className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed border-border rounded-lg hover:border-foreground/30 transition-colors"
|
|
>
|
|
+ Add label
|
|
</button>
|
|
)}
|
|
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
Use {altKey}{isMac ? '' : '+'}1 through {altKey}{isMac ? '' : '+'}0 when the annotation toolbar is visible to apply a label instantly.
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* === VIM TAB === */}
|
|
{activeTab === 'vim' && mode !== 'review' && (
|
|
<VimSettingsTab
|
|
vimModeEnabled={vimModeEnabled}
|
|
vimHudEnabled={vimHudEnabled}
|
|
vimHudKeyPanelEnabled={vimHudKeyPanelEnabled}
|
|
onVimModeChange={(enabled) => configStore.set('vimModeEnabled', enabled)}
|
|
onVimHudChange={(enabled) => configStore.set('vimHudEnabled', enabled)}
|
|
onVimHudKeyPanelChange={(enabled) =>
|
|
configStore.set('vimHudKeyPanelEnabled', enabled)}
|
|
/>
|
|
)}
|
|
|
|
{/* === SHORTCUTS TAB === */}
|
|
{activeTab === 'shortcuts' && (
|
|
<KeyboardShortcuts mode={mode} vimModeEnabled={vimModeEnabled} />
|
|
)}
|
|
|
|
{/* === COMMENTS TAB === */}
|
|
{activeTab === 'comments' && (
|
|
<CommentsTab />
|
|
)}
|
|
|
|
{/* === AI TAB === */}
|
|
{activeTab === 'ai' && (
|
|
<AISettingsTab
|
|
providers={aiProviders}
|
|
selectedProviderId={aiProvider}
|
|
origin={origin}
|
|
onProviderChange={setAiProvider}
|
|
/>
|
|
)}
|
|
|
|
{/* === FILES TAB === */}
|
|
{activeTab === 'files' && (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">File Browser</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Your project files are shown automatically. Add extra directories below.
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={fileBrowserSettings.enabled}
|
|
onClick={() => handleFileBrowserChange({ enabled: !fileBrowserSettings.enabled })}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
fileBrowserSettings.enabled ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
fileBrowserSettings.enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{fileBrowserSettings.enabled && (
|
|
<>
|
|
<div className="border-t border-border" />
|
|
|
|
{/* Directory list */}
|
|
{fileBrowserSettings.directories.length > 0 && (
|
|
<div className="space-y-1">
|
|
<label className="text-xs text-muted-foreground">Directories</label>
|
|
{fileBrowserSettings.directories.map((dir) => (
|
|
<div key={dir} className="flex items-center gap-2 group">
|
|
<div className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono truncate" title={dir}>
|
|
{dir}
|
|
</div>
|
|
<button
|
|
onClick={() => handleFileBrowserChange({
|
|
directories: fileBrowserSettings.directories.filter((d) => d !== dir),
|
|
})}
|
|
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
|
title="Remove directory"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add directory */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">Add Directory</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={newDirPath}
|
|
onChange={(e) => setNewDirPath(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') addDirectory();
|
|
}}
|
|
placeholder="/path/to/directory"
|
|
className="flex-1 px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
<button
|
|
onClick={addDirectory}
|
|
disabled={!newDirPath.trim()}
|
|
className="px-3 py-2 bg-primary text-primary-foreground rounded-lg text-xs font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
Add directories outside your project that contain markdown files.
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* === HOOKS TAB === */}
|
|
{activeTab === 'hooks' && <HooksTab />}
|
|
|
|
{/* === OBSIDIAN TAB === */}
|
|
{activeTab === 'obsidian' && (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">Obsidian Integration</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Auto-save approved plans to your vault
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={obsidian.enabled}
|
|
onClick={() => handleObsidianChange({ enabled: !obsidian.enabled })}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
obsidian.enabled ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
obsidian.enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{obsidian.enabled && (
|
|
<>
|
|
<div className="border-t border-border" />
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex gap-3">
|
|
<div className="flex-1 space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">Vault</label>
|
|
{vaultsLoading ? (
|
|
<div className="w-full px-3 py-2 bg-muted rounded-lg text-xs text-muted-foreground">
|
|
Detecting...
|
|
</div>
|
|
) : detectedVaults.length > 0 ? (
|
|
<>
|
|
<select
|
|
value={obsidian.vaultPath}
|
|
onChange={(e) => handleObsidianChange({ vaultPath: e.target.value })}
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
|
|
>
|
|
{detectedVaults.map((vault) => (
|
|
<option key={vault} value={vault}>
|
|
{vault.split('/').pop() || vault}
|
|
</option>
|
|
))}
|
|
<option value={CUSTOM_PATH_SENTINEL}>Custom path...</option>
|
|
</select>
|
|
{obsidian.vaultPath === CUSTOM_PATH_SENTINEL && (
|
|
<input
|
|
type="text"
|
|
value={obsidian.customPath || ''}
|
|
onChange={(e) => handleObsidianChange({ customPath: e.target.value })}
|
|
placeholder="/path/to/vault"
|
|
className="w-full mt-2 px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={obsidian.vaultPath}
|
|
onChange={(e) => handleObsidianChange({ vaultPath: e.target.value })}
|
|
placeholder="/path/to/vault"
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="w-44 space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">Folder</label>
|
|
<input
|
|
type="text"
|
|
value={obsidian.folder}
|
|
onChange={(e) => handleObsidianChange({ folder: e.target.value })}
|
|
placeholder="plannotator"
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">Filename Format</label>
|
|
<input
|
|
type="text"
|
|
value={obsidian.filenameFormat || ''}
|
|
onChange={(e) => handleObsidianChange({ filenameFormat: e.target.value || undefined })}
|
|
placeholder={DEFAULT_FILENAME_FORMAT}
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
Variables: <code className="text-[10px]">{'{title}'}</code> <code className="text-[10px]">{'{YYYY}'}</code> <code className="text-[10px]">{'{MM}'}</code> <code className="text-[10px]">{'{DD}'}</code> <code className="text-[10px]">{'{Mon}'}</code> <code className="text-[10px]">{'{D}'}</code> <code className="text-[10px]">{'{HH}'}</code> <code className="text-[10px]">{'{h}'}</code> <code className="text-[10px]">{'{hh}'}</code> <code className="text-[10px]">{'{mm}'}</code> <code className="text-[10px]">{'{ss}'}</code> <code className="text-[10px]">{'{ampm}'}</code>
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
Preview: {(() => {
|
|
const fmt = obsidian.filenameFormat?.trim() || DEFAULT_FILENAME_FORMAT;
|
|
const now = new Date();
|
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
const h24 = now.getHours(); const h12 = h24 % 12 || 12;
|
|
const vars: Record<string, string> = {
|
|
title: 'My Plan Title', YYYY: String(now.getFullYear()),
|
|
MM: String(now.getMonth()+1).padStart(2,'0'), DD: String(now.getDate()).padStart(2,'0'),
|
|
Mon: months[now.getMonth()], D: String(now.getDate()),
|
|
HH: String(h24).padStart(2,'0'), h: String(h12), hh: String(h12).padStart(2,'0'),
|
|
mm: String(now.getMinutes()).padStart(2,'0'), ss: String(now.getSeconds()).padStart(2,'0'),
|
|
ampm: h24 >= 12 ? 'pm' : 'am',
|
|
};
|
|
let preview = fmt.replace(/\{(\w+)\}/g, (m, k) => vars[k] ?? m) + '.md';
|
|
if (obsidian.filenameSeparator === 'dash') preview = preview.replace(/ /g, '-');
|
|
else if (obsidian.filenameSeparator === 'underscore') preview = preview.replace(/ /g, '_');
|
|
return preview;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">Filename Separator</label>
|
|
<select
|
|
value={obsidian.filenameSeparator || 'space'}
|
|
onChange={(e) => handleObsidianChange({ filenameSeparator: e.target.value as 'space' | 'dash' | 'underscore' })}
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
>
|
|
<option value="space">Spaces (default)</option>
|
|
<option value="dash">Dashes (-)</option>
|
|
<option value="underscore">Underscores (_)</option>
|
|
</select>
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
Replaces spaces in the generated filename. Useful when working with CLI tools in your vault.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
Plans saved to: {obsidian.vaultPath === CUSTOM_PATH_SENTINEL
|
|
? (obsidian.customPath || '...')
|
|
: (obsidian.vaultPath || '...')}/{obsidian.folder || 'plannotator'}/
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">Frontmatter (auto-generated)</label>
|
|
<pre className="px-3 py-2 bg-muted/50 rounded-lg text-[10px] font-mono text-muted-foreground overflow-x-auto">
|
|
{`---
|
|
created: ${new Date().toISOString().slice(0, 19)}Z
|
|
source: plannotator
|
|
tags: [plan, ...]
|
|
---`}
|
|
</pre>
|
|
</div>
|
|
|
|
<div className="border-t border-border/30" />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-xs font-medium">Auto-save on Plan Arrival</div>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
Automatically save to Obsidian when a plan loads, before you approve or deny
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={obsidian.autoSave}
|
|
onClick={() => handleObsidianChange({ autoSave: !obsidian.autoSave })}
|
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
|
|
obsidian.autoSave ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
obsidian.autoSave ? 'translate-x-6' : 'translate-x-1'
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-xs font-medium">Vault Browser</div>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
Browse and annotate vault files from the sidebar
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={obsidian.vaultBrowserEnabled}
|
|
onClick={() => handleObsidianChange({ vaultBrowserEnabled: !obsidian.vaultBrowserEnabled })}
|
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
|
|
obsidian.vaultBrowserEnabled ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
obsidian.vaultBrowserEnabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* === BEAR TAB === */}
|
|
{activeTab === 'bear' && (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">Bear Notes</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Auto-save approved plans to Bear
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={bear.enabled}
|
|
onClick={() => handleBearChange({ enabled: !bear.enabled })}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
bear.enabled ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
bear.enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
{bear.enabled && (
|
|
<div className="mt-3 space-y-3">
|
|
<div className="space-y-1.5 pl-0.5">
|
|
<label className="text-xs text-muted-foreground">Custom Tags</label>
|
|
<input
|
|
type="text"
|
|
value={bear.customTags}
|
|
onChange={(e) => handleBearChange({ customTags: e.target.value })}
|
|
onBlur={(e) => handleBearChange({ customTags: normalizeTags(e.target.value) })}
|
|
placeholder="plan, work"
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
Comma-separated, kebab-case. Leave empty for auto-generated tags.
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5 pl-0.5">
|
|
<label className="text-xs text-muted-foreground">Tag Position</label>
|
|
<select
|
|
value={bear.tagPosition}
|
|
onChange={(e) => handleBearChange({ tagPosition: e.target.value as 'prepend' | 'append' })}
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
>
|
|
<option value="append">Append (end of note)</option>
|
|
<option value="prepend">Prepend (after title)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="border-t border-border/30" />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-xs font-medium">Auto-save on Plan Arrival</div>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
Automatically save to Bear when a plan loads, before you approve or deny
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={bear.autoSave}
|
|
onClick={() => handleBearChange({ autoSave: !bear.autoSave })}
|
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
|
|
bear.autoSave ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
bear.autoSave ? 'translate-x-6' : 'translate-x-1'
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* === OCTARINE TAB === */}
|
|
{activeTab === 'octarine' && (
|
|
<>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-medium">Octarine</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Auto-save approved plans to Octarine
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={octarine.enabled}
|
|
onClick={() => handleOctarineChange({ enabled: !octarine.enabled })}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
octarine.enabled ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
octarine.enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
{octarine.enabled && (
|
|
<div className="mt-3 space-y-3">
|
|
<div className="space-y-1.5 pl-0.5">
|
|
<label className="text-xs text-muted-foreground">Workspace Name</label>
|
|
<input
|
|
type="text"
|
|
value={octarine.workspace}
|
|
onChange={(e) => handleOctarineChange({ workspace: e.target.value })}
|
|
placeholder="My Workspace"
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
The Octarine workspace name to save plans into.
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1.5 pl-0.5">
|
|
<label className="text-xs text-muted-foreground">Folder</label>
|
|
<input
|
|
type="text"
|
|
value={octarine.folder}
|
|
onChange={(e) => handleOctarineChange({ folder: e.target.value })}
|
|
placeholder="plannotator"
|
|
className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
|
/>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
Subfolder within the workspace for saved plans.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
Plans saved to: {octarine.workspace || '...'} / {octarine.folder || 'plannotator'}/
|
|
</div>
|
|
|
|
<div className="border-t border-border/30" />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="text-xs font-medium">Auto-save on Plan Arrival</div>
|
|
<div className="text-[10px] text-muted-foreground">
|
|
Automatically save to Octarine when a plan loads, before you approve or deny
|
|
</div>
|
|
</div>
|
|
<button
|
|
role="switch"
|
|
aria-checked={octarine.autoSave}
|
|
onClick={() => handleOctarineChange({ autoSave: !octarine.autoSave })}
|
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
|
|
octarine.autoSave ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
>
|
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
|
octarine.autoSave ? 'translate-x-6' : 'translate-x-1'
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
</OverlayScrollArea>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
|
|
{themePreview && createPortal(
|
|
<div className="fixed inset-0 z-[100] flex flex-col pointer-events-none">
|
|
<div className="flex-1" />
|
|
<div
|
|
className="pointer-events-auto w-full bg-card border-t-2 border-primary/30 shadow-[0_-4px_20px_rgba(0,0,0,0.4)] flex flex-col max-h-[35vh] overflow-hidden"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border flex-shrink-0">
|
|
<span className="text-xs font-medium text-muted-foreground">Theme Preview</span>
|
|
<button
|
|
onClick={() => { setThemePreview(false); setShowDialog(true); }}
|
|
className="px-2.5 py-1 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
<div className="p-3 overflow-y-auto flex-1 min-h-0">
|
|
<ThemeTab compact />
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
);
|
|
};
|