@plannotator/ui 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +6 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MenuVersionSection.tsx +4 -4
  19. package/components/MermaidBlock.tsx +1 -1
  20. package/components/ModeToggle.tsx +7 -6
  21. package/components/OpenInAppButton.tsx +2 -5
  22. package/components/PinpointOverlay.tsx +9 -7
  23. package/components/PlanHeaderMenu.tsx +8 -8
  24. package/components/PopoutDialog.tsx +6 -1
  25. package/components/ResizeHandle.tsx +1 -0
  26. package/components/Settings.tsx +172 -12
  27. package/components/SkillReferenceMenu.tsx +260 -0
  28. package/components/StickyHeaderLane.tsx +7 -0
  29. package/components/ThemeProvider.tsx +131 -32
  30. package/components/ThemeTab.tsx +123 -77
  31. package/components/ToolbarButtons.tsx +29 -8
  32. package/components/Viewer.tsx +396 -130
  33. package/components/VimKeyHud.tsx +695 -0
  34. package/components/VimModeAnnouncementDialog.tsx +557 -0
  35. package/components/VimModeOverlay.tsx +235 -0
  36. package/components/VimTargetReticle.tsx +284 -0
  37. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  38. package/components/blocks/CodeBlock.tsx +18 -18
  39. package/components/blocks/TablePopout.tsx +7 -8
  40. package/components/blocks/TableToolbar.tsx +7 -8
  41. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  42. package/components/html-viewer/HtmlViewer.tsx +450 -47
  43. package/components/html-viewer/annotationNumbering.ts +37 -0
  44. package/components/html-viewer/bridge-script.ts +4051 -298
  45. package/components/html-viewer/composerYield.ts +51 -0
  46. package/components/html-viewer/srcdoc.ts +18 -3
  47. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  48. package/components/icons/themeIcons.tsx +1 -1
  49. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  50. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  51. package/components/settings/HooksTab.tsx +12 -8
  52. package/components/sidebar/FileBrowser.tsx +4 -1
  53. package/components/themeModes.tsx +28 -0
  54. package/config/configStore.ts +76 -1
  55. package/config/settings.ts +152 -0
  56. package/configure.ts +9 -0
  57. package/globals.d.ts +7 -1
  58. package/hooks/useAIChat.ts +5 -2
  59. package/hooks/useAIProviderActivation.ts +47 -0
  60. package/hooks/useAIProviderConfig.ts +5 -1
  61. package/hooks/useAgentSettings.ts +64 -23
  62. package/hooks/useAgents.ts +4 -4
  63. package/hooks/useAnnotationHighlighter.ts +100 -3
  64. package/hooks/useArchive.ts +2 -1
  65. package/hooks/useFenceTheme.ts +17 -0
  66. package/hooks/useLinkedDoc.ts +68 -1
  67. package/hooks/usePinpoint.ts +76 -75
  68. package/hooks/usePlanDiff.ts +73 -2
  69. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  70. package/hooks/useUpdateCheck.ts +1 -2
  71. package/hooks/useVimDocumentFocus.ts +116 -0
  72. package/hooks/useVimSelection.ts +1063 -0
  73. package/package.json +4 -4
  74. package/print.css +14 -13
  75. package/shortcuts/core.ts +38 -13
  76. package/shortcuts/index.ts +10 -0
  77. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  78. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  79. package/shortcuts/runtime.ts +111 -12
  80. package/styles.css +1 -1
  81. package/theme.css +504 -0
  82. package/themes/colorblind.css +89 -0
  83. package/themes/plannotator.css +2 -2
  84. package/types.ts +93 -10
  85. package/utils/agentSwitch.ts +33 -7
  86. package/utils/blockTargeting.ts +462 -178
  87. package/utils/clipboard.ts +110 -0
  88. package/utils/codeBlockMark.ts +50 -0
  89. package/utils/codeHighlight.ts +293 -0
  90. package/utils/codexModels.ts +79 -0
  91. package/utils/domSelection.ts +84 -0
  92. package/utils/htmlChrome.ts +73 -0
  93. package/utils/inputMethod.ts +79 -6
  94. package/utils/parser.ts +517 -21
  95. package/utils/preferenceTtl.ts +15 -0
  96. package/utils/sharing.ts +0 -1
  97. package/utils/skillCatalog.ts +269 -0
  98. package/utils/skillReferences.ts +475 -0
  99. package/utils/syntaxTheme.ts +83 -0
  100. package/utils/themeRegistry.ts +154 -0
  101. package/utils/vimHud.ts +263 -0
  102. package/utils/vimModeAnnouncement.ts +23 -0
  103. package/utils/vimNavigation.ts +417 -0
  104. package/utils/vimReticle.ts +88 -0
  105. package/utils/vimScroll.ts +162 -0
@@ -1,8 +1,24 @@
1
- import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { configStore } from '../config/configStore';
3
+ import { readThemePairCookies, writeThemePairCookies } from '../config/settings';
4
+ import { useConfigValue } from '../config/useConfig';
2
5
  import { storage } from '../utils/storage';
3
- import { BUILT_IN_THEMES, type ThemeInfo } from '../utils/themeRegistry';
6
+ import {
7
+ BUILT_IN_THEMES,
8
+ getUnsupportedMode,
9
+ resolvePairTheme,
10
+ resolveThemeMode,
11
+ seedThemePair,
12
+ setDefaultThemePair,
13
+ themeSupportsHalf,
14
+ type ThemeHalf,
15
+ type ThemeInfo,
16
+ type ThemePair,
17
+ } from '../utils/themeRegistry';
18
+ import type { Mode } from './themeModes';
4
19
 
5
- export type Mode = 'dark' | 'light' | 'system';
20
+ // Kept here because published consumers already import Mode from ThemeProvider.
21
+ export type { Mode } from './themeModes';
6
22
 
7
23
  type ThemeProviderState = {
8
24
  // Mode (dark/light/system) — backward-compatible with old "theme" API
@@ -10,10 +26,15 @@ type ThemeProviderState = {
10
26
  setTheme: (mode: Mode) => void;
11
27
  mode: Mode;
12
28
  setMode: (mode: Mode) => void;
29
+ preferredMode: 'dark' | 'light';
13
30
  resolvedMode: 'dark' | 'light';
14
- // Color theme (palette)
31
+ // Color theme (the palette the current mode renders — pair[preferredMode])
15
32
  colorTheme: string;
16
33
  setColorTheme: (theme: string) => void;
34
+ // The pair itself: one palette per half, assignable independently
35
+ lightTheme: string;
36
+ darkTheme: string;
37
+ setHalfTheme: (half: ThemeHalf, theme: string) => void;
17
38
  availableThemes: ThemeInfo[];
18
39
  };
19
40
 
@@ -22,29 +43,21 @@ const ThemeProviderContext = createContext<ThemeProviderState>({
22
43
  setTheme: () => null,
23
44
  mode: 'dark',
24
45
  setMode: () => null,
46
+ preferredMode: 'dark',
25
47
  resolvedMode: 'dark',
26
48
  colorTheme: 'plannotator',
27
49
  setColorTheme: () => null,
50
+ lightTheme: 'plannotator',
51
+ darkTheme: 'plannotator',
52
+ setHalfTheme: () => null,
28
53
  availableThemes: BUILT_IN_THEMES,
29
54
  });
30
55
 
31
- /** Resolve the class string for a theme + mode combination */
32
- function resolveThemeClasses(themeId: string, effectiveMode: 'dark' | 'light'): string {
33
- const themeInfo = BUILT_IN_THEMES.find(t => t.id === themeId);
34
- const modeSupport = themeInfo?.modeSupport ?? 'both';
35
-
36
- let applyLight = effectiveMode === 'light';
37
- if (modeSupport === 'dark-only') applyLight = false;
38
- if (modeSupport === 'light-only') applyLight = true;
39
-
40
- return `theme-${themeId}${applyLight ? ' light' : ''}`;
41
- }
42
-
43
56
  /** Sync theme classes on <html> without stripping non-theme classes (e.g. transitions-ready). */
44
- function applyThemeClasses(themeId: string, effectiveMode: 'dark' | 'light'): void {
57
+ function applyThemeClasses(themeId: string, resolvedMode: 'dark' | 'light'): void {
45
58
  const el = document.documentElement;
46
59
  const themeClass = `theme-${themeId}`;
47
- const wantLight = resolveThemeClasses(themeId, effectiveMode).includes(' light');
60
+ const wantLight = resolvedMode === 'light';
48
61
 
49
62
  if (el.classList.contains(themeClass) && el.classList.contains('light') === wantLight) return;
50
63
 
@@ -66,7 +79,20 @@ interface ThemeProviderProps {
66
79
  children: React.ReactNode;
67
80
  defaultTheme?: Mode;
68
81
  defaultColorTheme?: string;
82
+ /**
83
+ * Where the mode is stored. Read when resolving the initial pair and written
84
+ * by the legacy mirror, so a host's already-stored preference survives the
85
+ * upgrade to pairs.
86
+ */
69
87
  storageKey?: string;
88
+ /**
89
+ * Where the single pre-pair palette is stored. Read as the migration source
90
+ * for both halves and kept in sync with the palette on screen.
91
+ *
92
+ * Note: the two halves themselves are a new concept with no pre-existing
93
+ * host data, so they always live under `plannotator-light-theme` /
94
+ * `plannotator-dark-theme`; these props rename only the two legacy values.
95
+ */
70
96
  colorThemeStorageKey?: string;
71
97
  }
72
98
 
@@ -77,18 +103,53 @@ export function ThemeProvider({
77
103
  storageKey = 'plannotator-theme',
78
104
  colorThemeStorageKey = 'plannotator-color-theme',
79
105
  }: ThemeProviderProps) {
80
- const [mode, setModeState] = useState<Mode>(
81
- () => (storage.getItem(storageKey) as Mode) || defaultTheme
106
+ const legacyKeys = useMemo(
107
+ () => ({ mode: storageKey, colorTheme: colorThemeStorageKey }),
108
+ [storageKey, colorThemeStorageKey],
82
109
  );
83
110
 
84
- const [colorTheme, setColorThemeState] = useState<string>(
85
- () => storage.getItem(colorThemeStorageKey) || defaultColorTheme
86
- );
111
+ // Resolve the pair this provider starts on from ITS OWN storage keys: what
112
+ // the user persisted (migrating a host's pre-pair values), else these props.
113
+ // The config store is a singleton that may already have resolved a default of
114
+ // its own, so storage is asked directly rather than trusting that value.
115
+ const [initialPair] = useState<ThemePair>(() => {
116
+ const resolved = readThemePairCookies(legacyKeys)
117
+ ?? seedThemePair(defaultColorTheme, defaultTheme);
118
+ setDefaultThemePair(resolved);
119
+ return resolved;
120
+ });
121
+ const pendingSeed = useRef<ThemePair | null>(initialPair);
122
+ const [, setSeedApplied] = useState(false);
123
+
124
+ const storePair = useConfigValue('themePair');
125
+ const pair = pendingSeed.current ?? storePair;
126
+ const mode = pair.mode;
127
+
128
+ // Hand the resolved pair to the store as a SEED, not a user choice: seeding
129
+ // writes memory + cookies only. Routing it through set() would queue a
130
+ // server write of a value nobody picked, which (flushing after the server
131
+ // config arrives) would overwrite the user's real ~/.plannotator/config.json
132
+ // theme from any cookie-less visit.
133
+ useEffect(() => {
134
+ const seed = pendingSeed.current;
135
+ if (!seed) return;
136
+ pendingSeed.current = null;
137
+ configStore.seed('themePair', seed);
138
+ setSeedApplied(true);
139
+ }, []);
87
140
 
88
141
  const [systemIsLight, setSystemIsLight] = useState(getSystemIsLight);
89
142
 
90
- // Compute resolved mode once — consumers use this instead of re-querying matchMedia
91
- const resolvedMode: 'dark' | 'light' = mode === 'system' ? (systemIsLight ? 'light' : 'dark') : mode;
143
+ // Keep the OS-resolved preference separate from the half it selects.
144
+ const preferredMode: 'dark' | 'light' =
145
+ mode === 'system' ? (systemIsLight ? 'light' : 'dark') : mode;
146
+ const colorTheme = resolvePairTheme(pair, preferredMode);
147
+ const resolvedMode = resolveThemeMode(colorTheme, preferredMode);
148
+
149
+ // Read by the legacy setColorTheme, which must target the half on screen
150
+ // without re-creating its callback on every mode change.
151
+ const preferredModeRef = useRef(preferredMode);
152
+ preferredModeRef.current = preferredMode;
92
153
 
93
154
  // [P3 fix] Apply theme class synchronously during initialization to prevent
94
155
  // flash of unstyled content. CSS tokens live under .theme-* selectors, so
@@ -126,25 +187,63 @@ export function ThemeProvider({
126
187
  }, [mode]);
127
188
 
128
189
  const setMode = useCallback((newMode: Mode) => {
129
- storage.setItem(storageKey, newMode);
130
- setModeState(newMode);
131
- }, [storageKey]);
190
+ configStore.set('themePair', { ...configStore.get('themePair'), mode: newMode });
191
+ }, []);
192
+
193
+ /** Assign one palette to one half of the pair. */
194
+ const setHalfTheme = useCallback((half: ThemeHalf, newTheme: string) => {
195
+ if (!themeSupportsHalf(newTheme, half)) return;
196
+ configStore.set('themePair', { ...configStore.get('themePair'), [half]: newTheme });
197
+ }, []);
132
198
 
199
+ /**
200
+ * Legacy single-palette API, kept for published consumers. It assigns exactly
201
+ * ONE half and changes nothing else:
202
+ *
203
+ * - a palette that renders both modes goes to the half currently on screen,
204
+ * leaving the other half's assignment alone;
205
+ * - a mode-restricted palette goes to the half it supports without touching
206
+ * the mode, because render-time resolution (`resolveThemeMode`) already
207
+ * keeps a System user on a palette that can be drawn.
208
+ *
209
+ * Persistence stays cookie-only unless a host installed a serverSync
210
+ * transport, matching the side effects this API had before the pair existed.
211
+ */
133
212
  const setColorTheme = useCallback((newTheme: string) => {
134
- storage.setItem(colorThemeStorageKey, newTheme);
135
- setColorThemeState(newTheme);
136
- }, [colorThemeStorageKey]);
213
+ const current = configStore.get('themePair');
214
+ const unsupported = getUnsupportedMode(newTheme);
215
+ const half: ThemeHalf = unsupported
216
+ ? (unsupported === 'light' ? 'dark' : 'light')
217
+ : preferredModeRef.current;
218
+ configStore.setLocal('themePair', { ...current, [half]: newTheme });
219
+ }, []);
220
+
221
+ // Mirror the resolved choice onto the keys older releases read, so a
222
+ // downgrade lands on the user's palette instead of an unstyled first frame.
223
+ // The pair itself is written first: a pair migrated from the legacy
224
+ // single-palette key is derived, and the mirror below overwrites the key it
225
+ // was derived from.
226
+ useEffect(() => {
227
+ writeThemePairCookies(pair, legacyKeys);
228
+ if (storage.getItem(colorThemeStorageKey) !== colorTheme) {
229
+ storage.setItem(colorThemeStorageKey, colorTheme);
230
+ }
231
+ }, [pair, colorTheme, colorThemeStorageKey, legacyKeys]);
137
232
 
138
233
  const value = useMemo<ThemeProviderState>(() => ({
139
234
  theme: mode,
140
235
  setTheme: setMode,
141
236
  mode,
142
237
  setMode,
238
+ preferredMode,
143
239
  resolvedMode,
144
240
  colorTheme,
145
241
  setColorTheme,
242
+ lightTheme: pair.light,
243
+ darkTheme: pair.dark,
244
+ setHalfTheme,
146
245
  availableThemes: BUILT_IN_THEMES,
147
- }), [mode, resolvedMode, colorTheme, setMode, setColorTheme]);
246
+ }), [mode, preferredMode, resolvedMode, colorTheme, pair.light, pair.dark, setMode, setColorTheme, setHalfTheme]);
148
247
 
149
248
  return (
150
249
  <ThemeProviderContext.Provider value={value}>
@@ -1,14 +1,63 @@
1
- import React from 'react';
2
- import { useTheme, type Mode } from './ThemeProvider';
3
- import { SunIcon, MoonIcon, SystemIcon } from './icons/themeIcons';
1
+ import React, { useEffect, useState } from 'react';
2
+ import { useTheme } from './ThemeProvider';
3
+ import { THEME_MODES } from './themeModes';
4
+ import { themesForHalf, type ThemeHalf } from '../utils/themeRegistry';
4
5
 
5
6
  interface ThemeTabProps {
6
7
  onPreview?: () => void;
7
8
  compact?: boolean;
8
9
  }
9
10
 
11
+ const HALVES: { id: ThemeHalf; label: string }[] = [
12
+ { id: 'light', label: 'Light' },
13
+ { id: 'dark', label: 'Dark' },
14
+ ];
15
+
16
+ const SyntaxLinesIcon: React.FC<{ className?: string }> = ({ className }) => (
17
+ <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
18
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
19
+ </svg>
20
+ );
21
+
10
22
  export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
11
- const { mode, setMode, colorTheme, setColorTheme, availableThemes, resolvedMode } = useTheme();
23
+ const {
24
+ mode,
25
+ setMode,
26
+ lightTheme,
27
+ darkTheme,
28
+ setHalfTheme,
29
+ availableThemes,
30
+ preferredMode,
31
+ } = useTheme();
32
+
33
+ // Which half the grid assigns to. Follows the mode you are actually seeing,
34
+ // so opening Settings in dark mode edits the dark half first.
35
+ const [half, setHalf] = useState<ThemeHalf>(preferredMode);
36
+ useEffect(() => setHalf(preferredMode), [preferredMode]);
37
+
38
+ const pair: Record<ThemeHalf, string> = { light: lightTheme, dark: darkTheme };
39
+ const themes = themesForHalf(availableThemes, half);
40
+ const nameOf = (id: string) => availableThemes.find(theme => theme.id === id)?.name ?? id;
41
+
42
+ const summary = (
43
+ <div className={`flex items-center gap-1.5 text-[11px] text-muted-foreground ${compact ? '' : 'flex-wrap'}`}>
44
+ {HALVES.map(({ id, label }, index) => (
45
+ <React.Fragment key={id}>
46
+ {index > 0 && <span className="text-muted-foreground/40">·</span>}
47
+ <button
48
+ onClick={() => setHalf(id)}
49
+ title={`Assign the ${label.toLowerCase()} theme`}
50
+ className={`rounded px-1 py-0.5 transition-colors hover:bg-muted ${
51
+ half === id ? 'text-foreground' : ''
52
+ }`}
53
+ >
54
+ <span className="text-muted-foreground/70">{label}: </span>
55
+ <span className="font-medium">{nameOf(pair[id])}</span>
56
+ </button>
57
+ </React.Fragment>
58
+ ))}
59
+ </div>
60
+ );
12
61
 
13
62
  return (
14
63
  <div className={compact ? '' : 'space-y-5'}>
@@ -16,98 +65,95 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
16
65
  <div className={compact ? 'flex items-center gap-3 mb-2' : 'space-y-2'}>
17
66
  {!compact && <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Mode</label>}
18
67
  <div className="flex gap-1">
19
- {(['dark', 'light', 'system'] as Mode[]).map(m => {
20
- const isActive = mode === m;
21
- return (
22
- <button
23
- key={m}
24
- onClick={() => setMode(m)}
25
- className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
26
- isActive
27
- ? 'bg-primary text-primary-foreground'
28
- : 'bg-muted text-muted-foreground hover:text-foreground'
29
- }`}
30
- >
31
- {m === 'dark' && (
32
- <span className="flex items-center gap-1.5">
33
- <MoonIcon className="w-3 h-3" />
34
- Dark
35
- </span>
36
- )}
37
- {m === 'light' && (
38
- <span className="flex items-center gap-1.5">
39
- <SunIcon className="w-3 h-3" />
40
- Light
41
- </span>
42
- )}
43
- {m === 'system' && (
44
- <span className="flex items-center gap-1.5">
45
- <SystemIcon className="w-3 h-3" />
46
- System
47
- </span>
48
- )}
49
- </button>
50
- );
51
- })}
68
+ {THEME_MODES.map(({ id, label, Icon }) => (
69
+ <button
70
+ key={id}
71
+ onClick={() => setMode(id)}
72
+ className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
73
+ mode === id
74
+ ? 'bg-primary text-primary-foreground'
75
+ : 'bg-muted text-muted-foreground hover:text-foreground'
76
+ }`}
77
+ >
78
+ <span className="flex items-center gap-1.5">
79
+ <Icon className="w-3 h-3" />
80
+ {label}
81
+ </span>
82
+ </button>
83
+ ))}
52
84
  </div>
53
- {compact && (
54
- <span className="text-[10px] text-muted-foreground/60 ml-auto flex items-center gap-1">
55
- <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
56
- <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
57
- </svg>
58
- syntax match
59
- </span>
85
+ {!compact && (
86
+ <p className="text-[11px] text-muted-foreground/70">
87
+ System follows your OS and switches between the two themes below.
88
+ </p>
60
89
  )}
90
+ {compact && <div className="ml-auto">{summary}</div>}
61
91
  </div>
62
92
 
63
- {/* Theme */}
64
- <div className={compact ? '' : 'space-y-3'}>
93
+ {/* Theme pair */}
94
+ <div className={compact ? 'space-y-2' : 'space-y-3'}>
65
95
  {!compact && (
66
- <div className="flex items-center justify-between border-t border-border pt-5">
67
- <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Theme</label>
68
- <div className="flex items-center gap-3">
69
- <span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
70
- <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
71
- <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
72
- </svg>
73
- = matched syntax colors
74
- </span>
75
- {onPreview && (
76
- <button
77
- onClick={onPreview}
78
- className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 text-primary border border-primary/20 hover:bg-primary/20 hover:border-primary/40 transition-colors"
79
- >
80
- Preview Mode
81
- </button>
82
- )}
96
+ <>
97
+ <div className="flex items-center justify-between border-t border-border pt-5">
98
+ <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Theme</label>
99
+ <div className="flex items-center gap-3">
100
+ <span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
101
+ <SyntaxLinesIcon className="w-2.5 h-2.5" />
102
+ = matched syntax colors
103
+ </span>
104
+ {onPreview && (
105
+ <button
106
+ onClick={onPreview}
107
+ className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 text-primary border border-primary/20 hover:bg-primary/20 hover:border-primary/40 transition-colors"
108
+ >
109
+ Preview Mode
110
+ </button>
111
+ )}
112
+ </div>
83
113
  </div>
84
- </div>
114
+ {summary}
115
+ </>
85
116
  )}
117
+
118
+ {/* Which half the grid assigns to */}
119
+ <div className="flex items-center gap-2">
120
+ <span className="text-[11px] text-muted-foreground/70">Assigning</span>
121
+ <div className="flex items-center gap-0.5 rounded-lg bg-muted/50 p-0.5">
122
+ {HALVES.map(({ id, label }) => (
123
+ <button
124
+ key={id}
125
+ onClick={() => setHalf(id)}
126
+ aria-pressed={half === id}
127
+ className={`rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors ${
128
+ half === id
129
+ ? 'bg-background text-foreground shadow-sm'
130
+ : 'text-muted-foreground hover:text-foreground'
131
+ }`}
132
+ >
133
+ {label} theme
134
+ </button>
135
+ ))}
136
+ </div>
137
+ </div>
138
+
86
139
  <div className={`grid gap-2 overflow-y-auto pr-1 ${compact ? 'grid-cols-4' : 'grid-cols-3'}`}>
87
- {availableThemes.map(theme => {
88
- const isSelected = colorTheme === theme.id;
89
- const colors = theme.colors[resolvedMode];
90
- const modeUnavailable =
91
- (resolvedMode === 'light' && theme.modeSupport === 'dark-only') ||
92
- (resolvedMode === 'dark' && theme.modeSupport === 'light-only');
140
+ {themes.map(theme => {
141
+ const isSelected = pair[half] === theme.id;
142
+ const colors = theme.colors[half];
93
143
  return (
94
144
  <button
95
145
  key={theme.id}
96
- onClick={() => setColorTheme(theme.id)}
146
+ onClick={() => setHalfTheme(half, theme.id)}
97
147
  className={`relative p-2 rounded-md border text-left transition-colors ${
98
148
  isSelected
99
149
  ? 'border-primary bg-primary/5'
100
- : modeUnavailable
101
- ? 'border-border/50 opacity-45'
102
- : 'border-border hover:border-muted-foreground/30 hover:bg-muted/30'
150
+ : 'border-border hover:border-muted-foreground/30 hover:bg-muted/30'
103
151
  }`}
104
152
  >
105
153
  {/* Syntax highlighting badge */}
106
154
  {theme.syntaxHighlighting && (
107
155
  <div className="absolute top-1 right-1" title="Matched syntax highlighting in diffs">
108
- <svg className="w-2.5 h-2.5 text-muted-foreground/50" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
109
- <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
110
- </svg>
156
+ <SyntaxLinesIcon className="w-2.5 h-2.5 text-muted-foreground/50" />
111
157
  </div>
112
158
  )}
113
159
  {/* Color swatches */}
@@ -1,7 +1,9 @@
1
1
  import React from 'react';
2
2
  import { Button } from './ui/button';
3
3
  import { cn } from '../lib/utils';
4
- import { Send, Check } from 'lucide-react';
4
+ import { Send, Check, X } from 'lucide-react';
5
+
6
+ type ToolbarLabelBreakpoint = 'md' | 'lg';
5
7
 
6
8
  interface FeedbackButtonProps {
7
9
  onClick: () => void;
@@ -13,6 +15,7 @@ interface FeedbackButtonProps {
13
15
  shortLoadingLabel?: string;
14
16
  title?: string;
15
17
  muted?: boolean;
18
+ labelBreakpoint?: ToolbarLabelBreakpoint;
16
19
  }
17
20
 
18
21
  export const FeedbackButton: React.FC<FeedbackButtonProps> = ({
@@ -25,6 +28,7 @@ export const FeedbackButton: React.FC<FeedbackButtonProps> = ({
25
28
  shortLoadingLabel,
26
29
  title = 'Send Feedback',
27
30
  muted = false,
31
+ labelBreakpoint = 'md',
28
32
  }) => (
29
33
  <Button
30
34
  variant="outline"
@@ -37,11 +41,17 @@ export const FeedbackButton: React.FC<FeedbackButtonProps> = ({
37
41
  >
38
42
  {shortLabel ? (
39
43
  <>
40
- <span className="hidden md:inline lg:hidden">{isLoading ? (shortLoadingLabel ?? loadingLabel) : shortLabel}</span>
41
- <span className="hidden lg:inline">{isLoading ? loadingLabel : label}</span>
44
+ <span className={labelBreakpoint === 'lg' ? 'hidden lg:inline xl:hidden' : 'hidden md:inline lg:hidden'}>
45
+ {isLoading ? (shortLoadingLabel ?? loadingLabel) : shortLabel}
46
+ </span>
47
+ <span className={labelBreakpoint === 'lg' ? 'hidden xl:inline' : 'hidden lg:inline'}>
48
+ {isLoading ? loadingLabel : label}
49
+ </span>
42
50
  </>
43
51
  ) : (
44
- <span className="hidden md:inline">{isLoading ? loadingLabel : label}</span>
52
+ <span className={labelBreakpoint === 'lg' ? 'hidden lg:inline' : 'hidden md:inline'}>
53
+ {isLoading ? loadingLabel : label}
54
+ </span>
45
55
  )}
46
56
  </Button>
47
57
  );
@@ -57,6 +67,7 @@ export interface ApproveButtonProps {
57
67
  title?: string;
58
68
  dimmed?: boolean;
59
69
  muted?: boolean;
70
+ labelBreakpoint?: ToolbarLabelBreakpoint;
60
71
  }
61
72
 
62
73
  export const ApproveButton: React.FC<ApproveButtonProps> = ({
@@ -70,6 +81,7 @@ export const ApproveButton: React.FC<ApproveButtonProps> = ({
70
81
  title,
71
82
  dimmed = false,
72
83
  muted = false,
84
+ labelBreakpoint = 'md',
73
85
  }) => (
74
86
  <Button
75
87
  variant="success"
@@ -84,8 +96,12 @@ export const ApproveButton: React.FC<ApproveButtonProps> = ({
84
96
  dimmed && !muted && !disabled && 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground',
85
97
  )}
86
98
  >
87
- <span className="md:hidden">{isLoading ? mobileLoadingLabel : mobileLabel}</span>
88
- <span className="hidden md:inline">{isLoading ? loadingLabel : label}</span>
99
+ <span className={labelBreakpoint === 'lg' ? 'lg:hidden' : 'md:hidden'}>
100
+ {isLoading ? mobileLoadingLabel : mobileLabel}
101
+ </span>
102
+ <span className={labelBreakpoint === 'lg' ? 'hidden lg:inline' : 'hidden md:inline'}>
103
+ {isLoading ? loadingLabel : label}
104
+ </span>
89
105
  </Button>
90
106
  );
91
107
 
@@ -94,6 +110,7 @@ interface ExitButtonProps {
94
110
  disabled?: boolean;
95
111
  isLoading?: boolean;
96
112
  title?: string;
113
+ labelBreakpoint?: ToolbarLabelBreakpoint;
97
114
  }
98
115
 
99
116
  export const ExitButton: React.FC<ExitButtonProps> = ({
@@ -101,6 +118,7 @@ export const ExitButton: React.FC<ExitButtonProps> = ({
101
118
  disabled = false,
102
119
  isLoading = false,
103
120
  title = 'Close session without sending feedback',
121
+ labelBreakpoint = 'md',
104
122
  }) => (
105
123
  <Button
106
124
  variant="secondary"
@@ -108,9 +126,12 @@ export const ExitButton: React.FC<ExitButtonProps> = ({
108
126
  onClick={onClick}
109
127
  disabled={disabled || isLoading}
110
128
  title={title}
129
+ aria-label={title}
111
130
  className="bg-muted text-muted-foreground hover:bg-muted/80"
112
131
  >
113
- <span className="md:hidden">{isLoading ? '...' : '✕'}</span>
114
- <span className="hidden md:inline">{isLoading ? 'Closing...' : 'Close'}</span>
132
+ <span className={labelBreakpoint === 'lg' ? 'lg:hidden' : 'md:hidden'}>
133
+ {isLoading ? '…' : <X className="size-3.5" aria-hidden="true" />}
134
+ </span>
135
+ <span className={labelBreakpoint === 'lg' ? 'hidden lg:inline' : 'hidden md:inline'}>{isLoading ? 'Closing...' : 'Close'}</span>
115
136
  </Button>
116
137
  );