diff options
Diffstat (limited to 'renderer')
| -rw-r--r-- | renderer/src/App.tsx | 7 | ||||
| -rw-r--r-- | renderer/src/components/Header.tsx | 7 | ||||
| -rw-r--r-- | renderer/src/components/SettingsPage.tsx | 51 | ||||
| -rw-r--r-- | renderer/src/components/settings/SystemPromptsSettings.tsx | 142 | ||||
| -rw-r--r-- | renderer/src/styles/globals.css | 213 |
5 files changed, 420 insertions, 0 deletions
diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index 74b1f91..7d75196 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx | |||
| @@ -3,6 +3,7 @@ import { Header } from "./components/Header"; | |||
| 3 | import { DocumentPane } from "./components/DocumentPane"; | 3 | import { DocumentPane } from "./components/DocumentPane"; |
| 4 | import { ChatPane } from "./components/ChatPane"; | 4 | import { ChatPane } from "./components/ChatPane"; |
| 5 | import { ActionBar } from "./components/ActionBar"; | 5 | import { ActionBar } from "./components/ActionBar"; |
| 6 | import { SettingsPage } from "./components/SettingsPage"; | ||
| 6 | import type { Project, Session, Message, Phase, TokenUsage } from "./types"; | 7 | import type { Project, Session, Message, Phase, TokenUsage } from "./types"; |
| 7 | import "./styles/globals.css"; | 8 | import "./styles/globals.css"; |
| 8 | 9 | ||
| @@ -56,6 +57,7 @@ export function App() { | |||
| 56 | outputTokens: 0, | 57 | outputTokens: 0, |
| 57 | }); | 58 | }); |
| 58 | const [error, setError] = useState<string | null>(null); | 59 | const [error, setError] = useState<string | null>(null); |
| 60 | const [showSettings, setShowSettings] = useState(false); | ||
| 59 | 61 | ||
| 60 | const [theme, setTheme] = useState<Theme>( | 62 | const [theme, setTheme] = useState<Theme>( |
| 61 | () => (localStorage.getItem("cf-theme") as Theme) ?? "dark" | 63 | () => (localStorage.getItem("cf-theme") as Theme) ?? "dark" |
| @@ -365,6 +367,7 @@ export function App() { | |||
| 365 | theme={theme} | 367 | theme={theme} |
| 366 | onToggleTheme={handleToggleTheme} | 368 | onToggleTheme={handleToggleTheme} |
| 367 | gitBranch={selectedSession?.git_branch ?? null} | 369 | gitBranch={selectedSession?.git_branch ?? null} |
| 370 | onOpenSettings={() => setShowSettings(true)} | ||
| 368 | /> | 371 | /> |
| 369 | 372 | ||
| 370 | <div className="main-content"> | 373 | <div className="main-content"> |
| @@ -413,6 +416,10 @@ export function App() { | |||
| 413 | }} | 416 | }} |
| 414 | disabled={!selectedSession} | 417 | disabled={!selectedSession} |
| 415 | /> | 418 | /> |
| 419 | |||
| 420 | {showSettings && ( | ||
| 421 | <SettingsPage onClose={() => setShowSettings(false)} /> | ||
| 422 | )} | ||
| 416 | </div> | 423 | </div> |
| 417 | ); | 424 | ); |
| 418 | } | 425 | } |
diff --git a/renderer/src/components/Header.tsx b/renderer/src/components/Header.tsx index fc0289d..3a530d3 100644 --- a/renderer/src/components/Header.tsx +++ b/renderer/src/components/Header.tsx | |||
| @@ -18,6 +18,7 @@ interface HeaderProps { | |||
| 18 | theme: Theme; | 18 | theme: Theme; |
| 19 | onToggleTheme: () => void; | 19 | onToggleTheme: () => void; |
| 20 | gitBranch: string | null; | 20 | gitBranch: string | null; |
| 21 | onOpenSettings: () => void; | ||
| 21 | } | 22 | } |
| 22 | 23 | ||
| 23 | const phaseLabels: Record<Phase, string> = { | 24 | const phaseLabels: Record<Phase, string> = { |
| @@ -43,6 +44,7 @@ export function Header({ | |||
| 43 | theme, | 44 | theme, |
| 44 | onToggleTheme, | 45 | onToggleTheme, |
| 45 | gitBranch, | 46 | gitBranch, |
| 47 | onOpenSettings, | ||
| 46 | }: HeaderProps) { | 48 | }: HeaderProps) { |
| 47 | const handleDeleteProject = () => { | 49 | const handleDeleteProject = () => { |
| 48 | if (!selectedProject || !onDeleteProject) return; | 50 | if (!selectedProject || !onDeleteProject) return; |
| @@ -222,6 +224,11 @@ export function Header({ | |||
| 222 | <button className="theme-toggle" onClick={onToggleTheme}> | 224 | <button className="theme-toggle" onClick={onToggleTheme}> |
| 223 | {theme === "dark" ? "[light]" : "[dark]"} | 225 | {theme === "dark" ? "[light]" : "[dark]"} |
| 224 | </button> | 226 | </button> |
| 227 | |||
| 228 | {/* ── Settings button ── */} | ||
| 229 | <button className="settings-btn" onClick={onOpenSettings} title="Settings"> | ||
| 230 | ⚙ | ||
| 231 | </button> | ||
| 225 | </div> | 232 | </div> |
| 226 | </header> | 233 | </header> |
| 227 | ); | 234 | ); |
diff --git a/renderer/src/components/SettingsPage.tsx b/renderer/src/components/SettingsPage.tsx new file mode 100644 index 0000000..5267665 --- /dev/null +++ b/renderer/src/components/SettingsPage.tsx | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | import React, { useState } from "react"; | ||
| 2 | import { SystemPromptsSettings } from "./settings/SystemPromptsSettings"; | ||
| 3 | |||
| 4 | type SettingsSection = "system-prompts"; | ||
| 5 | |||
| 6 | interface SettingsPageProps { | ||
| 7 | onClose: () => void; | ||
| 8 | } | ||
| 9 | |||
| 10 | export function SettingsPage({ onClose }: SettingsPageProps) { | ||
| 11 | const [activeSection, setActiveSection] = | ||
| 12 | useState<SettingsSection>("system-prompts"); | ||
| 13 | |||
| 14 | return ( | ||
| 15 | <div className="settings-overlay"> | ||
| 16 | {/* Header — matches the main app header height/style */} | ||
| 17 | <div className="settings-header"> | ||
| 18 | <div className="settings-header-left"> | ||
| 19 | <span className="settings-title">\u2699 Settings</span> | ||
| 20 | </div> | ||
| 21 | <button | ||
| 22 | className="settings-close" | ||
| 23 | onClick={onClose} | ||
| 24 | title="Close settings" | ||
| 25 | > | ||
| 26 | \u00d7 | ||
| 27 | </button> | ||
| 28 | </div> | ||
| 29 | |||
| 30 | <div className="settings-body"> | ||
| 31 | {/* Side nav */} | ||
| 32 | <nav className="settings-nav"> | ||
| 33 | <button | ||
| 34 | className={`settings-nav-item${ | ||
| 35 | activeSection === "system-prompts" ? " active" : "" | ||
| 36 | }`} | ||
| 37 | onClick={() => setActiveSection("system-prompts")} | ||
| 38 | > | ||
| 39 | System Prompts | ||
| 40 | </button> | ||
| 41 | {/* Future sections added here */} | ||
| 42 | </nav> | ||
| 43 | |||
| 44 | {/* Content */} | ||
| 45 | <div className="settings-content"> | ||
| 46 | {activeSection === "system-prompts" && <SystemPromptsSettings />} | ||
| 47 | </div> | ||
| 48 | </div> | ||
| 49 | </div> | ||
| 50 | ); | ||
| 51 | } | ||
diff --git a/renderer/src/components/settings/SystemPromptsSettings.tsx b/renderer/src/components/settings/SystemPromptsSettings.tsx new file mode 100644 index 0000000..a0c6a22 --- /dev/null +++ b/renderer/src/components/settings/SystemPromptsSettings.tsx | |||
| @@ -0,0 +1,142 @@ | |||
| 1 | import React, { useState, useEffect } from "react"; | ||
| 2 | import type { Phase } from "../../types"; | ||
| 3 | |||
| 4 | const PHASES: Phase[] = ["research", "plan", "implement"]; | ||
| 5 | const PHASE_LABELS: Record<Phase, string> = { | ||
| 6 | research: "Research", | ||
| 7 | plan: "Plan", | ||
| 8 | implement: "Implement", | ||
| 9 | }; | ||
| 10 | |||
| 11 | const api = window.api; | ||
| 12 | |||
| 13 | export function SystemPromptsSettings() { | ||
| 14 | const [activePhase, setActivePhase] = useState<Phase>("research"); | ||
| 15 | // Hardcoded default text per phase (fetched once from main process) | ||
| 16 | const [defaults, setDefaults] = useState<Record<Phase, string> | null>(null); | ||
| 17 | // What is currently saved in the DB per phase (null = not customized) | ||
| 18 | const [saved, setSaved] = useState<Record<Phase, string | null>>({ | ||
| 19 | research: null, | ||
| 20 | plan: null, | ||
| 21 | implement: null, | ||
| 22 | }); | ||
| 23 | // Live textarea content per phase — initialised to saved ?? default on load | ||
| 24 | const [texts, setTexts] = useState<Record<Phase, string>>({ | ||
| 25 | research: "", | ||
| 26 | plan: "", | ||
| 27 | implement: "", | ||
| 28 | }); | ||
| 29 | const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle"); | ||
| 30 | |||
| 31 | useEffect(() => { | ||
| 32 | Promise.all([ | ||
| 33 | api.getDefaultSystemPrompts(), | ||
| 34 | api.getSettings([ | ||
| 35 | "systemPrompt.research", | ||
| 36 | "systemPrompt.plan", | ||
| 37 | "systemPrompt.implement", | ||
| 38 | ]), | ||
| 39 | ]).then(([defs, settings]) => { | ||
| 40 | const d = defs as Record<Phase, string>; | ||
| 41 | const s: Record<Phase, string | null> = { | ||
| 42 | research: settings["systemPrompt.research"], | ||
| 43 | plan: settings["systemPrompt.plan"], | ||
| 44 | implement: settings["systemPrompt.implement"], | ||
| 45 | }; | ||
| 46 | setDefaults(d); | ||
| 47 | setSaved(s); | ||
| 48 | setTexts({ | ||
| 49 | research: s.research ?? d.research, | ||
| 50 | plan: s.plan ?? d.plan, | ||
| 51 | implement: s.implement ?? d.implement, | ||
| 52 | }); | ||
| 53 | }); | ||
| 54 | }, []); | ||
| 55 | |||
| 56 | if (!defaults) { | ||
| 57 | return ( | ||
| 58 | <div style={{ color: "var(--text-secondary)", fontSize: 12 }}> | ||
| 59 | Loading... | ||
| 60 | </div> | ||
| 61 | ); | ||
| 62 | } | ||
| 63 | |||
| 64 | const currentText = texts[activePhase]; | ||
| 65 | const isCustomized = saved[activePhase] !== null; | ||
| 66 | // Dirty = textarea differs from what is in DB (or from default if not customized) | ||
| 67 | const isDirty = currentText !== (saved[activePhase] ?? defaults[activePhase]); | ||
| 68 | |||
| 69 | const handleChange = (val: string) => { | ||
| 70 | setTexts(prev => ({ ...prev, [activePhase]: val })); | ||
| 71 | setSaveStatus("idle"); | ||
| 72 | }; | ||
| 73 | |||
| 74 | const handleSave = async () => { | ||
| 75 | setSaveStatus("saving"); | ||
| 76 | await api.setSetting(`systemPrompt.${activePhase}`, currentText); | ||
| 77 | setSaved(prev => ({ ...prev, [activePhase]: currentText })); | ||
| 78 | setSaveStatus("saved"); | ||
| 79 | setTimeout(() => setSaveStatus("idle"), 2000); | ||
| 80 | }; | ||
| 81 | |||
| 82 | const handleReset = async () => { | ||
| 83 | await api.deleteSetting(`systemPrompt.${activePhase}`); | ||
| 84 | setSaved(prev => ({ ...prev, [activePhase]: null })); | ||
| 85 | setTexts(prev => ({ ...prev, [activePhase]: defaults[activePhase] })); | ||
| 86 | setSaveStatus("idle"); | ||
| 87 | }; | ||
| 88 | |||
| 89 | return ( | ||
| 90 | <div> | ||
| 91 | <div className="settings-section-title">System Prompts</div> | ||
| 92 | <div className="settings-section-desc"> | ||
| 93 | Customize the instructions sent to Claude at the start of each workflow | ||
| 94 | phase. Changes take effect on the next message sent in any session. | ||
| 95 | </div> | ||
| 96 | |||
| 97 | <div className="settings-tabs"> | ||
| 98 | {PHASES.map(phase => ( | ||
| 99 | <button | ||
| 100 | key={phase} | ||
| 101 | className={`settings-tab${activePhase === phase ? " active" : ""}`} | ||
| 102 | onClick={() => setActivePhase(phase)} | ||
| 103 | > | ||
| 104 | {PHASE_LABELS[phase]} | ||
| 105 | {saved[phase] !== null ? " \u25cf" : ""} | ||
| 106 | </button> | ||
| 107 | ))} | ||
| 108 | </div> | ||
| 109 | |||
| 110 | <textarea | ||
| 111 | className={`settings-textarea${isCustomized ? " is-custom" : ""}`} | ||
| 112 | value={currentText} | ||
| 113 | onChange={e => handleChange(e.target.value)} | ||
| 114 | spellCheck={false} | ||
| 115 | /> | ||
| 116 | |||
| 117 | <div className="settings-actions"> | ||
| 118 | {isCustomized && ( | ||
| 119 | <button className="btn-secondary" onClick={handleReset}> | ||
| 120 | Reset to Default | ||
| 121 | </button> | ||
| 122 | )} | ||
| 123 | <button | ||
| 124 | className="btn-primary" | ||
| 125 | onClick={handleSave} | ||
| 126 | disabled={saveStatus === "saving" || !isDirty} | ||
| 127 | > | ||
| 128 | {saveStatus === "saving" | ||
| 129 | ? "Saving\u2026" | ||
| 130 | : saveStatus === "saved" | ||
| 131 | ? "Saved \u2713" | ||
| 132 | : "Save"} | ||
| 133 | </button> | ||
| 134 | {(isCustomized || isDirty) && ( | ||
| 135 | <span className="settings-custom-badge"> | ||
| 136 | {isDirty ? "unsaved" : "custom"} | ||
| 137 | </span> | ||
| 138 | )} | ||
| 139 | </div> | ||
| 140 | </div> | ||
| 141 | ); | ||
| 142 | } | ||
diff --git a/renderer/src/styles/globals.css b/renderer/src/styles/globals.css index ef0275e..61a37c0 100644 --- a/renderer/src/styles/globals.css +++ b/renderer/src/styles/globals.css | |||
| @@ -692,3 +692,216 @@ html[data-theme="light"] .chat-input input:focus { | |||
| 692 | border-left: 3px solid var(--accent); | 692 | border-left: 3px solid var(--accent); |
| 693 | border-radius: 0 2px 2px 0; | 693 | border-radius: 0 2px 2px 0; |
| 694 | } | 694 | } |
| 695 | |||
| 696 | /* ── Settings Overlay ────────────────────────────────────────── */ | ||
| 697 | .settings-overlay { | ||
| 698 | position: fixed; | ||
| 699 | inset: 0; | ||
| 700 | background: var(--bg-primary); | ||
| 701 | z-index: 100; | ||
| 702 | display: flex; | ||
| 703 | flex-direction: column; | ||
| 704 | } | ||
| 705 | |||
| 706 | .settings-header { | ||
| 707 | display: flex; | ||
| 708 | justify-content: space-between; | ||
| 709 | align-items: center; | ||
| 710 | padding: 10px 16px; | ||
| 711 | background: var(--bg-secondary); | ||
| 712 | border-bottom: 1px solid var(--border); | ||
| 713 | -webkit-app-region: drag; | ||
| 714 | } | ||
| 715 | |||
| 716 | .settings-header-left { | ||
| 717 | display: flex; | ||
| 718 | align-items: center; | ||
| 719 | gap: 10px; | ||
| 720 | -webkit-app-region: no-drag; | ||
| 721 | } | ||
| 722 | |||
| 723 | .settings-title { | ||
| 724 | font-size: 12px; | ||
| 725 | font-weight: 700; | ||
| 726 | letter-spacing: 0.15em; | ||
| 727 | text-transform: uppercase; | ||
| 728 | color: var(--text-primary); | ||
| 729 | } | ||
| 730 | |||
| 731 | .settings-close { | ||
| 732 | background: transparent; | ||
| 733 | border: 1px solid var(--border); | ||
| 734 | border-radius: 2px; | ||
| 735 | color: var(--text-primary); | ||
| 736 | cursor: pointer; | ||
| 737 | font-size: 14px; | ||
| 738 | padding: 4px 9px; | ||
| 739 | font-family: inherit; | ||
| 740 | -webkit-app-region: no-drag; | ||
| 741 | transition: background 0.15s; | ||
| 742 | } | ||
| 743 | |||
| 744 | .settings-close:hover { | ||
| 745 | background: var(--bg-tertiary); | ||
| 746 | } | ||
| 747 | |||
| 748 | .settings-body { | ||
| 749 | flex: 1; | ||
| 750 | display: flex; | ||
| 751 | overflow: hidden; | ||
| 752 | } | ||
| 753 | |||
| 754 | /* ── Settings Side Nav ───────────────────────────────────────── */ | ||
| 755 | .settings-nav { | ||
| 756 | width: 180px; | ||
| 757 | background: var(--bg-secondary); | ||
| 758 | border-right: 1px solid var(--border); | ||
| 759 | padding: 16px 0; | ||
| 760 | flex-shrink: 0; | ||
| 761 | } | ||
| 762 | |||
| 763 | .settings-nav-item { | ||
| 764 | display: block; | ||
| 765 | width: 100%; | ||
| 766 | text-align: left; | ||
| 767 | padding: 8px 16px; | ||
| 768 | background: none; | ||
| 769 | border: none; | ||
| 770 | border-left: 2px solid transparent; | ||
| 771 | cursor: pointer; | ||
| 772 | font-family: inherit; | ||
| 773 | font-size: 12px; | ||
| 774 | letter-spacing: 0.05em; | ||
| 775 | color: var(--text-secondary); | ||
| 776 | transition: background 0.1s, color 0.1s; | ||
| 777 | } | ||
| 778 | |||
| 779 | .settings-nav-item:hover { | ||
| 780 | background: var(--bg-tertiary); | ||
| 781 | color: var(--text-primary); | ||
| 782 | } | ||
| 783 | |||
| 784 | .settings-nav-item.active { | ||
| 785 | color: var(--text-primary); | ||
| 786 | border-left-color: var(--accent); | ||
| 787 | background: var(--bg-tertiary); | ||
| 788 | } | ||
| 789 | |||
| 790 | /* ── Settings Content Area ───────────────────────────────────── */ | ||
| 791 | .settings-content { | ||
| 792 | flex: 1; | ||
| 793 | overflow-y: auto; | ||
| 794 | padding: 32px 40px; | ||
| 795 | max-width: 860px; | ||
| 796 | } | ||
| 797 | |||
| 798 | .settings-section-title { | ||
| 799 | font-size: 13px; | ||
| 800 | font-weight: 700; | ||
| 801 | letter-spacing: 0.1em; | ||
| 802 | text-transform: uppercase; | ||
| 803 | color: var(--text-primary); | ||
| 804 | margin-bottom: 6px; | ||
| 805 | } | ||
| 806 | |||
| 807 | .settings-section-desc { | ||
| 808 | font-size: 12px; | ||
| 809 | color: var(--text-secondary); | ||
| 810 | line-height: 1.6; | ||
| 811 | margin-bottom: 24px; | ||
| 812 | } | ||
| 813 | |||
| 814 | /* ── Phase Tab Strip ─────────────────────────────────────────── */ | ||
| 815 | .settings-tabs { | ||
| 816 | display: flex; | ||
| 817 | border-bottom: 1px solid var(--border); | ||
| 818 | margin-bottom: 16px; | ||
| 819 | } | ||
| 820 | |||
| 821 | .settings-tab { | ||
| 822 | padding: 6px 16px; | ||
| 823 | background: none; | ||
| 824 | border: none; | ||
| 825 | border-bottom: 2px solid transparent; | ||
| 826 | margin-bottom: -1px; | ||
| 827 | cursor: pointer; | ||
| 828 | font-family: inherit; | ||
| 829 | font-size: 11px; | ||
| 830 | letter-spacing: 0.07em; | ||
| 831 | text-transform: uppercase; | ||
| 832 | color: var(--text-secondary); | ||
| 833 | transition: color 0.1s; | ||
| 834 | } | ||
| 835 | |||
| 836 | .settings-tab:hover { | ||
| 837 | color: var(--text-primary); | ||
| 838 | } | ||
| 839 | |||
| 840 | .settings-tab.active { | ||
| 841 | color: var(--accent); | ||
| 842 | border-bottom-color: var(--accent); | ||
| 843 | } | ||
| 844 | |||
| 845 | /* ── Prompt Textarea ─────────────────────────────────────────── */ | ||
| 846 | .settings-textarea { | ||
| 847 | width: 100%; | ||
| 848 | min-height: 420px; | ||
| 849 | padding: 14px; | ||
| 850 | background: var(--bg-secondary); | ||
| 851 | border: 1px solid var(--border); | ||
| 852 | border-radius: 2px; | ||
| 853 | color: var(--text-primary); | ||
| 854 | font-family: inherit; | ||
| 855 | font-size: 12px; | ||
| 856 | line-height: 1.7; | ||
| 857 | resize: vertical; | ||
| 858 | transition: border-color 0.15s, box-shadow 0.15s; | ||
| 859 | } | ||
| 860 | |||
| 861 | .settings-textarea:focus { | ||
| 862 | outline: none; | ||
| 863 | border-color: var(--accent); | ||
| 864 | box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.15); | ||
| 865 | } | ||
| 866 | |||
| 867 | html[data-theme="light"] .settings-textarea:focus { | ||
| 868 | box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15); | ||
| 869 | } | ||
| 870 | |||
| 871 | .settings-textarea.is-custom { | ||
| 872 | border-color: var(--warning); | ||
| 873 | } | ||
| 874 | |||
| 875 | /* ── Settings Actions Row ────────────────────────────────────── */ | ||
| 876 | .settings-actions { | ||
| 877 | display: flex; | ||
| 878 | align-items: center; | ||
| 879 | gap: 10px; | ||
| 880 | margin-top: 12px; | ||
| 881 | } | ||
| 882 | |||
| 883 | .settings-custom-badge { | ||
| 884 | margin-left: auto; | ||
| 885 | font-size: 10px; | ||
| 886 | letter-spacing: 0.08em; | ||
| 887 | text-transform: uppercase; | ||
| 888 | color: var(--warning); | ||
| 889 | } | ||
| 890 | |||
| 891 | /* ── Header Settings Button ──────────────────────────────────── */ | ||
| 892 | .settings-btn { | ||
| 893 | padding: 5px 8px; | ||
| 894 | background: transparent; | ||
| 895 | border: 1px solid var(--border); | ||
| 896 | border-radius: 2px; | ||
| 897 | color: var(--text-secondary); | ||
| 898 | cursor: pointer; | ||
| 899 | font-size: 14px; | ||
| 900 | font-family: inherit; | ||
| 901 | transition: background 0.15s, color 0.15s; | ||
| 902 | } | ||
| 903 | |||
| 904 | .settings-btn:hover { | ||
| 905 | background: var(--bg-tertiary); | ||
| 906 | color: var(--text-primary); | ||
| 907 | } | ||
