-
Notifications
You must be signed in to change notification settings - Fork 519
Expand file tree
/
Copy pathagent-mode-toggle.tsx
More file actions
243 lines (216 loc) · 6.3 KB
/
agent-mode-toggle.tsx
File metadata and controls
243 lines (216 loc) · 6.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
import React, { useEffect, useRef, useState } from 'react'
import { SegmentedControl } from './segmented-control'
import { useTheme } from '../hooks/use-theme'
import { BORDER_CHARS } from '../utils/ui-constants'
import { Button } from './button'
import type { Segment } from './segmented-control'
import type { AgentMode } from '../utils/constants'
const MODE_LABELS: Record<AgentMode, string> = {
DEFAULT: 'DEFAULT',
MAX: 'MAX',
PLAN: 'PLAN',
}
const ALL_MODES = Object.keys(MODE_LABELS) as AgentMode[]
export const OPEN_DELAY_MS = 0 // Delay before expanding on hover
export const CLOSE_DELAY_MS = 250 // Delay before collapsing when mouse leaves
export const REOPEN_SUPPRESS_MS = 250 // Time to block reopening after explicit close (prevents flicker)
/**
* Manages the open/close state with hover delays and reopen suppression.
* Provides timer-based state transitions to create smooth hover interactions.
*/
export function useHoverToggle() {
const [isOpen, setIsOpen] = useState(false)
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const openTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const reopenBlockedUntilRef = useRef<number>(0)
// Timer cleanup helpers
const clearOpenTimer = () => {
clearTimeout(openTimeoutRef.current!)
openTimeoutRef.current = null
}
const clearCloseTimer = () => {
clearTimeout(closeTimeoutRef.current!)
closeTimeoutRef.current = null
}
const clearAllTimers = () => {
clearOpenTimer()
clearCloseTimer()
}
// State transition actions
const openNow = () => {
clearAllTimers()
setIsOpen(true)
}
const closeNow = (suppressReopen = false) => {
clearAllTimers()
setIsOpen(false)
if (suppressReopen) {
reopenBlockedUntilRef.current = Date.now() + REOPEN_SUPPRESS_MS
}
}
const scheduleOpen = () => {
if (isOpen) return
if (Date.now() < reopenBlockedUntilRef.current) return
clearOpenTimer()
openTimeoutRef.current = setTimeout(() => {
openNow()
}, OPEN_DELAY_MS)
}
const scheduleClose = () => {
if (!isOpen) return
clearCloseTimer()
closeTimeoutRef.current = setTimeout(() => {
closeNow()
}, CLOSE_DELAY_MS)
}
// Cleanup on unmount
useEffect(() => {
return () => clearAllTimers()
}, [])
return {
isOpen,
openNow,
closeNow,
scheduleOpen,
scheduleClose,
// Expose individual timer clear helpers so callers can
// cancel the opposite pending action during hover transitions.
// These were used by the component but not returned previously,
// causing runtime errors when hover events fired.
clearOpenTimer,
clearCloseTimer,
clearAllTimers,
}
}
/**
* Builds the segment configuration for the expanded state.
* Shows all modes plus an active indicator with reversed arrow.
*/
export function buildExpandedSegments(currentMode: AgentMode): Segment[] {
return [
// All mode options (disabled for current mode)
...ALL_MODES.map((m) => ({
id: m,
label: MODE_LABELS[m],
isBold: false,
disabled: m === currentMode,
})),
// Active mode indicator with reversed arrow
{
id: `active-${currentMode}`,
label: `> ${MODE_LABELS[currentMode]}`,
isSelected: true,
defaultHighlighted: true,
},
]
}
export type AgentModeClickAction =
| { type: 'closeActive' }
| { type: 'selectMode'; mode: AgentMode }
| { type: 'toggleMode'; mode: AgentMode }
/**
* Decide what high-level action a click on a segment should perform.
* Extracted for unit testing and clarity.
*/
export const resolveAgentModeClick = (
currentMode: AgentMode,
clickedId: string,
hasOnSelectMode: boolean,
): AgentModeClickAction => {
if (clickedId.startsWith('active-')) return { type: 'closeActive' }
const target = clickedId as AgentMode
if (hasOnSelectMode) {
return { type: 'selectMode', mode: target }
}
return { type: 'toggleMode', mode: target }
}
/**
* AgentModeToggle
*
* Compact, hover-expandable segmented control for switching agent modes.
* - Clicking the current mode toggles expansion (open/close)
* - Clicking a different mode calls `onSelectMode` when provided,
* otherwise falls back to `onToggle`
*/
export const AgentModeToggle = ({
mode,
onToggle,
onSelectMode,
}: {
mode: AgentMode
onToggle: () => void
onSelectMode?: (mode: AgentMode) => void
}) => {
const theme = useTheme()
const [isCollapsedHovered, setIsCollapsedHovered] = useState(false)
const hoverToggle = useHoverToggle()
const handleMouseOver = () => {
hoverToggle.clearCloseTimer()
hoverToggle.scheduleOpen()
}
const handleMouseOut = () => {
hoverToggle.scheduleClose()
setIsCollapsedHovered(false)
}
const handleSegmentClick = (id: string) => {
const action = resolveAgentModeClick(mode, id, !!onSelectMode)
if (action.type === 'closeActive') {
hoverToggle.closeNow(true)
return
}
if (action.type === 'selectMode') {
onSelectMode?.(action.mode)
hoverToggle.closeNow(true)
return
}
// Toggle fallback (no onSelectMode provided)
hoverToggle.clearAllTimers()
onToggle()
hoverToggle.closeNow(true)
}
if (!hoverToggle.isOpen) {
return (
<Button
style={{
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 1,
paddingRight: 1,
borderStyle: 'single',
borderColor: isCollapsedHovered ? theme.foreground : theme.border,
customBorderChars: BORDER_CHARS,
}}
onClick={() => {
hoverToggle.clearAllTimers()
hoverToggle.openNow()
}}
onMouseOver={() => {
setIsCollapsedHovered(true)
handleMouseOver()
}}
onMouseOut={handleMouseOut}
>
<text
wrapMode="none"
fg={isCollapsedHovered ? theme.foreground : theme.muted}
>
{isCollapsedHovered ? (
<b>{`< ${MODE_LABELS[mode]}`}</b>
) : (
`< ${MODE_LABELS[mode]}`
)}
</text>
</Button>
)
}
// Expanded state: delegate rendering to SegmentedControl
const segments: Segment[] = buildExpandedSegments(mode)
return (
<SegmentedControl
segments={segments}
onSegmentClick={handleSegmentClick}
onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut}
/>
)
}