Skip to content

Commit ba15fb3

Browse files
Simplify question prompt UI with space-saving design (#166)
* ralph: ralph-question-minimizerestore-feature completed after 2 iterations * Simplify question prompt UI with space-saving design - Remove redundant navigation chevrons, keep only title click to toggle - Add drag-down gesture support to minimize question prompt - Show minimize button and chevron icons to indicate toggle capability - Update minimized indicator with orange theme matching app accent - Add Question: prefix to minimized state for clarity - Hide dismiss button on mobile minimized state - Increase title bar padding for better touch targets
1 parent 72a2356 commit ba15fb3

File tree

3 files changed

+149
-88
lines changed

3 files changed

+149
-88
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { X } from 'lucide-react'
2+
import type { QuestionRequest } from '@/api/types'
3+
4+
interface MinimizedQuestionIndicatorProps {
5+
question: QuestionRequest
6+
onRestore: () => void
7+
onDismiss: () => void
8+
}
9+
10+
export function MinimizedQuestionIndicator({
11+
question,
12+
onRestore,
13+
onDismiss
14+
}: MinimizedQuestionIndicatorProps) {
15+
const questionCount = question.questions.length
16+
const firstQuestionHeader = question.questions[0]?.header
17+
18+
return (
19+
<div className="w-full bg-gradient-to-br from-orange-100 to-orange-200 dark:from-orange-950 dark:to-orange-900 border-2 border-orange-300 dark:border-orange-700 rounded-lg shadow-lg mb-2 overflow-hidden">
20+
<div className="flex items-center px-3 py-2 sm:px-4 sm:py-2.5 border-b border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-900/50">
21+
<button
22+
onClick={onRestore}
23+
className="flex-1 text-left text-xs font-semibold text-orange-600 dark:text-white"
24+
>
25+
{questionCount === 1
26+
? `Question: ${firstQuestionHeader || 'Question pending'}`
27+
: `${questionCount} questions pending`
28+
}
29+
</button>
30+
<button
31+
onClick={onDismiss}
32+
className="p-1.5 hover:bg-red-500/20 text-muted-foreground hover:text-red-500 transition-colors hidden sm:block"
33+
>
34+
<X className="w-3.5 h-3.5" />
35+
</button>
36+
</div>
37+
</div>
38+
)
39+
}

frontend/src/components/session/QuestionPrompt.tsx

Lines changed: 85 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useRef, useCallback } from 'react'
2-
import { X, ChevronLeft, ChevronRight, Check, Loader2 } from 'lucide-react'
2+
import { X, ChevronRight, ChevronDown, ChevronUp, Check, Loader2 } from 'lucide-react'
33
import { Button } from '@/components/ui/button'
44
import { Textarea } from '@/components/ui/textarea'
55
import type { QuestionRequest, QuestionInfo } from '@/api/types'
@@ -10,19 +10,45 @@ interface QuestionPromptProps {
1010
question: QuestionRequest
1111
onReply: (requestID: string, answers: string[][]) => Promise<void>
1212
onReject: (requestID: string) => Promise<void>
13+
onMinimize?: () => void
1314
}
1415

15-
export function QuestionPrompt({ question, onReply, onReject }: QuestionPromptProps) {
16+
export function QuestionPrompt({ question, onReply, onReject, onMinimize }: QuestionPromptProps) {
1617
const questions = question.questions
1718
const isSingleSelect = questions.length === 1 && !questions[0]?.multiple
1819
const totalSteps = isSingleSelect ? 1 : questions.length + 1
1920

21+
const [isMinimized, setIsMinimized] = useState(false)
2022
const [currentIndex, setCurrentIndex] = useState(0)
2123
const [answers, setAnswers] = useState<string[][]>(() => questions.map(() => []))
2224
const [customInputs, setCustomInputs] = useState<string[]>(() => questions.map(() => ''))
2325
const [confirmedCustoms, setConfirmedCustoms] = useState<string[]>(() => questions.map(() => ''))
2426
const [expandedOther, setExpandedOther] = useState<number | null>(null)
2527
const [isSubmitting, setIsSubmitting] = useState(false)
28+
const [touchStartY, setTouchStartY] = useState<number | null>(null)
29+
30+
const handleMinimize = useCallback(() => {
31+
setIsMinimized(true)
32+
onMinimize?.()
33+
}, [onMinimize])
34+
35+
const handleTouchStart = useCallback((e: React.TouchEvent) => {
36+
setTouchStartY(e.touches[0].clientY)
37+
}, [])
38+
39+
const handleTouchMove = useCallback((e: React.TouchEvent) => {
40+
if (touchStartY === null) return
41+
const touchY = e.touches[0].clientY
42+
const diff = touchY - touchStartY
43+
if (diff > 100) {
44+
handleMinimize()
45+
setTouchStartY(null)
46+
}
47+
}, [touchStartY, handleMinimize])
48+
49+
const handleTouchEnd = useCallback(() => {
50+
setTouchStartY(null)
51+
}, [])
2652

2753
const isConfirmStep = !isSingleSelect && currentIndex === questions.length
2854
const currentQuestion = isConfirmStep ? null : questions[currentIndex]
@@ -35,13 +61,6 @@ export function QuestionPrompt({ question, onReply, onReject }: QuestionPromptPr
3561
}
3662
}, [currentIndex, totalSteps])
3763

38-
const goToPrev = useCallback(() => {
39-
if (currentIndex > 0) {
40-
setCurrentIndex(prev => prev - 1)
41-
setExpandedOther(null)
42-
}
43-
}, [currentIndex])
44-
4564
const handleSubmitSingle = useCallback(async (label: string) => {
4665
setIsSubmitting(true)
4766
try {
@@ -177,94 +196,75 @@ export function QuestionPrompt({ question, onReply, onReject }: QuestionPromptPr
177196

178197
const allQuestionsAnswered = questions.every((_, i) => hasAnswerForQuestion(i))
179198

199+
useEffect(() => {
200+
const handleKeyDown = (e: KeyboardEvent) => {
201+
if (e.key === 'Escape' && !isMinimized && !expandedOther) {
202+
handleMinimize()
203+
onMinimize?.()
204+
}
205+
}
206+
window.addEventListener('keydown', handleKeyDown)
207+
return () => {
208+
window.removeEventListener('keydown', handleKeyDown)
209+
}
210+
}, [isMinimized, expandedOther, handleMinimize, onMinimize])
211+
180212
return (
181213
<div
182-
className="w-full bg-gradient-to-br from-blue-100 to-blue-200 dark:from-blue-950 dark:to-blue-900 border-2 border-blue-300 dark:border-blue-700 rounded-xl shadow-lg shadow-blue-500/20 mb-3 overflow-hidden"
214+
className="w-full bg-gradient-to-br from-blue-100 to-blue-200 dark:from-blue-950 dark:to-blue-900 border-2 border-blue-300 dark:border-blue-700 rounded-xl shadow-lg shadow-blue-500/20 mb-1 overflow-hidden"
183215
>
184-
<div className="flex items-center justify-between px-2 py-1.5 sm:px-3 sm:py-2 border-b border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/50">
185-
<div className="flex items-center gap-2">
186-
{totalSteps > 1 && (
187-
<button
188-
onClick={goToPrev}
189-
disabled={currentIndex === 0}
190-
className="p-0.5 sm:p-1 rounded hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
191-
>
192-
<ChevronLeft className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-blue-600 dark:text-blue-400" />
193-
</button>
194-
)}
195-
<span className="text-xs sm:text-sm font-semibold text-blue-600 dark:text-white">
196-
{isConfirmStep ? 'Review' : (
197-
totalSteps > 1
198-
? `Question ${currentIndex + 1}/${questions.length}`
199-
: (currentQuestion?.header || 'Question')
200-
)}
201-
</span>
202-
{totalSteps > 1 && (
203-
<button
204-
onClick={goToNext}
205-
disabled={currentIndex === totalSteps - 1}
206-
className="p-0.5 sm:p-1 rounded hover:bg-blue-200 dark:hover:bg-blue-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
207-
>
208-
<ChevronRight className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-blue-600 dark:text-blue-400" />
209-
</button>
210-
)}
211-
</div>
216+
<div className="flex items-center px-3 py-2 sm:px-4 sm:py-2.5 border-b border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/50">
217+
<button
218+
onClick={() => isMinimized ? setIsMinimized(false) : handleMinimize()}
219+
className="flex items-center gap-1.5 flex-1 text-left text-xs sm:text-sm font-semibold text-blue-600 dark:text-white"
220+
>
221+
{isMinimized
222+
? <ChevronUp className="w-3 h-3 opacity-60 flex-shrink-0" />
223+
: <ChevronDown className="w-3 h-3 opacity-60 flex-shrink-0" />
224+
}
225+
{isConfirmStep ? 'Review' : (currentQuestion?.header || 'Question')}
226+
</button>
212227
<button
213228
onClick={handleReject}
214229
disabled={isSubmitting}
215-
className="p-1 sm:p-1.5 rounded-lg hover:bg-red-500/20 text-muted-foreground hover:text-red-500 transition-colors"
230+
className="p-1.5 sm:p-2 hover:bg-red-500/20 text-muted-foreground hover:text-red-500 transition-colors"
216231
>
217232
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
218233
</button>
219234
</div>
220235

221-
<div className="p-2 sm:p-3 max-h-[50vh] sm:max-h-[70vh] overflow-y-auto overflow-x-hidden bg-background/60 dark:bg-black/30">
222-
{isConfirmStep ? (
223-
<ConfirmStep
224-
questions={questions}
225-
answers={answers}
226-
onEditQuestion={setCurrentIndex}
227-
/>
228-
) : currentQuestion && (
229-
<QuestionStep
230-
question={currentQuestion}
231-
answers={answers[currentIndex] ?? []}
232-
customInput={customInputs[currentIndex] ?? ''}
233-
confirmedCustom={confirmedCustoms[currentIndex] ?? ''}
234-
expandedOther={expandedOther === currentIndex}
235-
isMultiSelect={isMultiSelect}
236-
onSelectOption={(label) => selectOption(currentIndex, label)}
237-
onExpandOther={() => handleExpandOther(currentIndex)}
238-
onCustomInputChange={(value) => handleCustomInput(currentIndex, value)}
239-
onConfirmCustomInput={() => confirmCustomInput(currentIndex)}
240-
onCollapseOther={() => setExpandedOther(null)}
241-
/>
242-
)}
243-
</div>
244-
245-
{totalSteps > 1 && (
246-
<div className="flex justify-center gap-1 sm:gap-1.5 py-1.5 sm:py-2 border-t border-blue-200 dark:border-blue-800">
247-
{Array.from({ length: totalSteps }).map((_, i) => (
248-
<button
249-
key={i}
250-
onClick={() => {
251-
setCurrentIndex(i)
252-
setExpandedOther(null)
253-
}}
254-
className={cn(
255-
"w-1.5 h-1.5 sm:w-2 sm:h-2 rounded-full transition-all duration-200",
256-
i === currentIndex
257-
? "bg-blue-500 scale-125"
258-
: i < questions.length && hasAnswerForQuestion(i)
259-
? "bg-green-500/70 hover:bg-green-500"
260-
: "bg-blue-500/30 hover:bg-blue-500/50"
261-
)}
236+
{!isMinimized ? (
237+
<div
238+
className="p-2 sm:p-3 max-h-[50vh] sm:max-h-[70vh] overflow-y-auto overflow-x-hidden bg-background/60 dark:bg-black/30"
239+
onTouchStart={handleTouchStart}
240+
onTouchMove={handleTouchMove}
241+
onTouchEnd={handleTouchEnd}
242+
>
243+
{isConfirmStep ? (
244+
<ConfirmStep
245+
questions={questions}
246+
answers={answers}
247+
onEditQuestion={setCurrentIndex}
262248
/>
263-
))}
249+
) : currentQuestion && (
250+
<QuestionStep
251+
question={currentQuestion}
252+
answers={answers[currentIndex] ?? []}
253+
customInput={customInputs[currentIndex] ?? ''}
254+
confirmedCustom={confirmedCustoms[currentIndex] ?? ''}
255+
expandedOther={expandedOther === currentIndex}
256+
isMultiSelect={isMultiSelect}
257+
onSelectOption={(label) => selectOption(currentIndex, label)}
258+
onExpandOther={() => handleExpandOther(currentIndex)}
259+
onCustomInputChange={(value) => handleCustomInput(currentIndex, value)}
260+
onConfirmCustomInput={() => confirmCustomInput(currentIndex)}
261+
onCollapseOther={() => setExpandedOther(null)}
262+
/>
263+
)}
264264
</div>
265-
)}
265+
) : null}
266266

267-
<div className="flex gap-1.5 sm:gap-2 px-2 py-2 sm:px-3 sm:py-3">
267+
<div className="flex gap-1.5 sm:gap-2 px-2 py-2 sm:px-3 sm:py-3 border-t border-blue-200 dark:border-blue-800">
268268
<Button
269269
size="sm"
270270
onClick={handleReject}
@@ -291,12 +291,10 @@ export function QuestionPrompt({ question, onReply, onReject }: QuestionPromptPr
291291
<Button
292292
size="sm"
293293
onClick={handleNext}
294-
disabled={currentIndex === totalSteps - 1 || (expandedOther === currentIndex && !customInputs[currentIndex]?.trim())}
294+
disabled={isSubmitting || (expandedOther === currentIndex && !customInputs[currentIndex]?.trim())}
295295
className="flex-1 h-8 sm:h-10 text-xs sm:text-sm bg-emerald-600 hover:bg-emerald-700 text-white"
296296
>
297-
<span className="hidden sm:inline">{expandedOther === currentIndex ? 'Confirm' : (currentIndex === questions.length - 1 ? 'Review' : 'Next')}</span>
298-
<span className="sm:hidden">{expandedOther === currentIndex ? 'OK' : (currentIndex === questions.length - 1 ? 'Review' : '→')}</span>
299-
<ChevronRight className="hidden sm:block w-4 h-4 ml-1" />
297+
{expandedOther === currentIndex ? 'Confirm' : 'Next'}
300298
</Button>
301299
)
302300
)}

frontend/src/pages/SessionDetail.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { createOpenCodeClient } from "@/api/opencode";
4242
import { useSessionStatus, useSessionStatusForSession } from "@/stores/sessionStatusStore";
4343
import { useQuestions } from "@/contexts/EventContext";
4444
import { QuestionPrompt } from "@/components/session/QuestionPrompt";
45+
import { MinimizedQuestionIndicator } from "@/components/session/MinimizedQuestionIndicator";
4546
import { PendingActionsGroup } from "@/components/notifications/PendingActionsGroup";
4647
import { SourceControlPanel } from "@/components/source-control";
4748
import { SessionTodoDisplay } from "@/components/message/SessionTodoDisplay";
@@ -73,6 +74,7 @@ export function SessionDetail() {
7374
const [selectedFilePath, setSelectedFilePath] = useState<string | undefined>();
7475
const [showScrollButton, setShowScrollButton] = useState(false);
7576
const [hasPromptContent, setHasPromptContent] = useState(false);
77+
const [minimizedQuestion, setMinimizedQuestion] = useState<QuestionRequest | null>(null);
7678

7779
const handleSwipeBack = useCallback(() => {
7880
navigate(`/repos/${repoId}`);
@@ -148,6 +150,20 @@ export function SessionDetail() {
148150
const handleShowSessionsDialog = useCallback(() => setSessionsDialogOpen(true), []);
149151
const handleShowHelpDialog = useCallback(() => openSettings(), [openSettings]);
150152

153+
const handleMinimizeQuestion = useCallback((question: QuestionRequest) => {
154+
setMinimizedQuestion(question)
155+
}, [])
156+
157+
const handleRestoreQuestion = useCallback(() => {
158+
setMinimizedQuestion(null)
159+
}, [])
160+
161+
useEffect(() => {
162+
if (minimizedQuestion && minimizedQuestion.sessionID !== sessionId) {
163+
setMinimizedQuestion(null)
164+
}
165+
}, [sessionId, minimizedQuestion])
166+
151167
const handleNewSession = useCallback(async () => {
152168
try {
153169
const newSession = await createSession.mutateAsync({ agent: undefined });
@@ -517,12 +533,20 @@ export function SessionDetail() {
517533
{ttsEnabled && lastAssistantText && !hasPromptContent && !hasActiveStream && (
518534
<FloatingTTSButton content={lastAssistantText} />
519535
)}
520-
{currentQuestion && currentQuestion.sessionID === sessionId && (
536+
{minimizedQuestion && minimizedQuestion.sessionID === sessionId && (
537+
<MinimizedQuestionIndicator
538+
question={minimizedQuestion}
539+
onRestore={handleRestoreQuestion}
540+
onDismiss={() => rejectQuestion(minimizedQuestion.id)}
541+
/>
542+
)}
543+
{!minimizedQuestion && currentQuestion && currentQuestion.sessionID === sessionId && (
521544
<QuestionPrompt
522545
key={currentQuestion.id}
523546
question={currentQuestion}
524547
onReply={replyToQuestion}
525548
onReject={rejectQuestion}
549+
onMinimize={() => handleMinimizeQuestion(currentQuestion)}
526550
/>
527551
)}
528552
<PromptInput

0 commit comments

Comments
 (0)