| ... | @@ -92,6 +92,8 @@ import { | ... | @@ -92,6 +92,8 @@ import { |
| 92 | buildPlanImplementationThreadTitle, | 92 | buildPlanImplementationThreadTitle, |
| 93 | buildPlanImplementationPrompt, | 93 | buildPlanImplementationPrompt, |
| 94 | buildProposedPlanMarkdownFilename, | 94 | buildProposedPlanMarkdownFilename, |
| | 95 | downloadPlanAsTextFile, |
| | 96 | normalizePlanMarkdownForExport, |
| 95 | proposedPlanTitle, | 97 | proposedPlanTitle, |
| 96 | resolvePlanFollowUpSubmission, | 98 | resolvePlanFollowUpSubmission, |
| 97 | } from "../proposedPlan"; | 99 | } from "../proposedPlan"; |
| ... | @@ -122,6 +124,7 @@ import { | ... | @@ -122,6 +124,7 @@ import { |
| 122 | shortcutLabelForCommand, | 124 | shortcutLabelForCommand, |
| 123 | } from "../keybindings"; | 125 | } from "../keybindings"; |
| 124 | import ChatMarkdown from "./ChatMarkdown"; | 126 | import ChatMarkdown from "./ChatMarkdown"; |
| | 127 | import PlanSidebar from "./PlanSidebar"; |
| 125 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 128 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 126 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; | 129 | import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 127 | import { | 130 | import { |
| ... | @@ -135,6 +138,7 @@ import { | ... | @@ -135,6 +138,7 @@ import { |
| 135 | DiffIcon, | 138 | DiffIcon, |
| 136 | EllipsisIcon, | 139 | EllipsisIcon, |
| 137 | FolderClosedIcon, | 140 | FolderClosedIcon, |
| | 141 | ListTodoIcon, |
| 138 | LockIcon, | 142 | LockIcon, |
| 139 | LockOpenIcon, | 143 | LockOpenIcon, |
| 140 | Undo2Icon, | 144 | Undo2Icon, |
| ... | @@ -291,21 +295,7 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | ... | @@ -291,21 +295,7 @@ function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 291 | return "text-muted-foreground/40"; | 295 | return "text-muted-foreground/40"; |
| 292 | } | 296 | } |
| 293 | | 297 | |
| 294 | function normalizePlanMarkdownForExport(planMarkdown: string): string { | | |
| 295 | return `${planMarkdown.trimEnd()}\n`; | | |
| 296 | } | | |
| 297 | | 298 | |
| 298 | function downloadTextFile(filename: string, contents: string): void { | | |
| 299 | const blob = new Blob([contents], { type: "text/markdown;charset=utf-8" }); | | |
| 300 | const url = URL.createObjectURL(blob); | | |
| 301 | const anchor = document.createElement("a"); | | |
| 302 | anchor.href = url; | | |
| 303 | anchor.download = filename; | | |
| 304 | anchor.click(); | | |
| 305 | window.setTimeout(() => { | | |
| 306 | URL.revokeObjectURL(url); | | |
| 307 | }, 0); | | |
| 308 | } | | |
| 309 | | 299 | |
| 310 | interface ExpandedImageItem { | 300 | interface ExpandedImageItem { |
| 311 | src: string; | 301 | src: string; |
| ... | @@ -656,6 +646,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -656,6 +646,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 656 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = | 646 | const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = |
| 657 | useState<Record<string, number>>({}); | 647 | useState<Record<string, number>>({}); |
| 658 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); | 648 | const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({}); |
| | 649 | const [planSidebarOpen, setPlanSidebarOpen] = useState(false); |
| | 650 | // Tracks whether the user explicitly dismissed the sidebar for the active turn. |
| | 651 | const planSidebarDismissedForTurnRef = useRef<string | null>(null); |
| | 652 | // When set, the thread-change reset effect will open the sidebar instead of closing it. |
| | 653 | // Used by "Implement in new thread" to carry the sidebar-open intent across navigation. |
| | 654 | const planSidebarOpenOnNextThreadRef = useRef(false); |
| 659 | const [nowTick, setNowTick] = useState(() => Date.now()); | 655 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 660 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); | 656 | const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); |
| 661 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); | 657 | const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null); |
| ... | @@ -1941,8 +1937,17 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1941,8 +1937,17 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1941 | | 1937 | |
| 1942 | useEffect(() => { | 1938 | useEffect(() => { |
| 1943 | setExpandedWorkGroups({}); | 1939 | setExpandedWorkGroups({}); |
| | 1940 | if (planSidebarOpenOnNextThreadRef.current) { |
| | 1941 | planSidebarOpenOnNextThreadRef.current = false; |
| | 1942 | setPlanSidebarOpen(true); |
| | 1943 | } else { |
| | 1944 | setPlanSidebarOpen(false); |
| | 1945 | } |
| | 1946 | planSidebarDismissedForTurnRef.current = null; |
| 1944 | }, [activeThread?.id]); | 1947 | }, [activeThread?.id]); |
| 1945 | | 1948 | |
| | 1949 | |
| | 1950 | |
| 1946 | useEffect(() => { | 1951 | useEffect(() => { |
| 1947 | if (!composerMenuOpen) { | 1952 | if (!composerMenuOpen) { |
| 1948 | setComposerHighlightedItemId(null); | 1953 | setComposerHighlightedItemId(null); |
| ... | @@ -2955,6 +2960,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2955,6 +2960,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2955 | interactionMode: nextInteractionMode, | 2960 | interactionMode: nextInteractionMode, |
| 2956 | createdAt: messageCreatedAt, | 2961 | createdAt: messageCreatedAt, |
| 2957 | }); | 2962 | }); |
| | 2963 | // Optimistically open the plan sidebar when implementing (not refining). |
| | 2964 | // "default" mode here means the agent is executing the plan, which produces |
| | 2965 | // step-tracking activities that the sidebar will display. |
| | 2966 | if (nextInteractionMode === "default") { |
| | 2967 | planSidebarDismissedForTurnRef.current = null; |
| | 2968 | setPlanSidebarOpen(true); |
| | 2969 | } |
| 2958 | sendInFlightRef.current = false; | 2970 | sendInFlightRef.current = false; |
| 2959 | } catch (err) { | 2971 | } catch (err) { |
| 2960 | setOptimisticUserMessages((existing) => | 2972 | setOptimisticUserMessages((existing) => |
| ... | @@ -3063,6 +3075,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3063,6 +3075,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3063 | .then(() => api.orchestration.getSnapshot()) | 3075 | .then(() => api.orchestration.getSnapshot()) |
| 3064 | .then((snapshot) => { | 3076 | .then((snapshot) => { |
| 3065 | syncServerReadModel(snapshot); | 3077 | syncServerReadModel(snapshot); |
| | 3078 | // Signal that the plan sidebar should open on the new thread. |
| | 3079 | planSidebarOpenOnNextThreadRef.current = true; |
| 3066 | return navigate({ | 3080 | return navigate({ |
| 3067 | to: "/$threadId", | 3081 | to: "/$threadId", |
| 3068 | params: { threadId: nextThreadId }, | 3082 | params: { threadId: nextThreadId }, |
| ... | @@ -3469,7 +3483,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3469,7 +3483,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3469 | error={activeThread.error} | 3483 | error={activeThread.error} |
| 3470 | onDismiss={() => setThreadError(activeThread.id, null)} | 3484 | onDismiss={() => setThreadError(activeThread.id, null)} |
| 3471 | /> | 3485 | /> |
| 3472 | <PlanModePanel activePlan={activePlan} /> | 3486 | {/* Main content area with optional plan sidebar */} |
| | 3487 | <div className="flex min-h-0 flex-1"> |
| | 3488 | {/* Chat column */} |
| | 3489 | <div className="flex min-h-0 min-w-0 flex-1 flex-col"> |
| 3473 | | 3490 | |
| 3474 | {/* Messages */} | 3491 | {/* Messages */} |
| 3475 | <div | 3492 | <div |
| ... | @@ -3543,6 +3560,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3543,6 +3560,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3543 | answers={activePendingDraftAnswers} | 3560 | answers={activePendingDraftAnswers} |
| 3544 | questionIndex={activePendingQuestionIndex} | 3561 | questionIndex={activePendingQuestionIndex} |
| 3545 | onSelectOption={onSelectActivePendingUserInputOption} | 3562 | onSelectOption={onSelectActivePendingUserInputOption} |
| | 3563 | onAdvance={onAdvanceActivePendingUserInput} |
| 3546 | /> | 3564 | /> |
| 3547 | </div> | 3565 | </div> |
| 3548 | ) : showPlanFollowUpPrompt && activeProposedPlan ? ( | 3566 | ) : showPlanFollowUpPrompt && activeProposedPlan ? ( |
| ... | @@ -3749,6 +3767,43 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3749,6 +3767,43 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3749 | {runtimeMode === "full-access" ? "Full access" : "Supervised"} | 3767 | {runtimeMode === "full-access" ? "Full access" : "Supervised"} |
| 3750 | </span> | 3768 | </span> |
| 3751 | </Button> | 3769 | </Button> |
| | 3770 | |
| | 3771 | {/* Plan sidebar toggle */} |
| | 3772 | {(activePlan || activeProposedPlan || planSidebarOpen) ? ( |
| | 3773 | <> |
| | 3774 | <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" /> |
| | 3775 | <Button |
| | 3776 | variant="ghost" |
| | 3777 | className={cn( |
| | 3778 | "shrink-0 whitespace-nowrap px-2 sm:px-3", |
| | 3779 | planSidebarOpen |
| | 3780 | ? "text-blue-400 hover:text-blue-300" |
| | 3781 | : "text-muted-foreground/70 hover:text-foreground/80", |
| | 3782 | )} |
| | 3783 | size="sm" |
| | 3784 | type="button" |
| | 3785 | onClick={() => { |
| | 3786 | setPlanSidebarOpen((open) => { |
| | 3787 | if (open) { |
| | 3788 | // Closing: track dismissal for current turn |
| | 3789 | const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; |
| | 3790 | if (turnKey) { |
| | 3791 | planSidebarDismissedForTurnRef.current = turnKey; |
| | 3792 | } |
| | 3793 | } else { |
| | 3794 | // Re-opening: clear dismissal tracking |
| | 3795 | planSidebarDismissedForTurnRef.current = null; |
| | 3796 | } |
| | 3797 | return !open; |
| | 3798 | }); |
| | 3799 | }} |
| | 3800 | title={planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"} |
| | 3801 | > |
| | 3802 | <ListTodoIcon /> |
| | 3803 | <span className="sr-only sm:not-sr-only">Plan</span> |
| | 3804 | </Button> |
| | 3805 | </> |
| | 3806 | ) : null} |
| 3752 | </div> | 3807 | </div> |
| 3753 | | 3808 | |
| 3754 | {/* Right side: send / stop button */} | 3809 | {/* Right side: send / stop button */} |
| ... | @@ -3915,6 +3970,27 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3915,6 +3970,27 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3915 | </form> | 3970 | </form> |
| 3916 | </div> | 3971 | </div> |
| 3917 | | 3972 | |
| | 3973 | </div>{/* end chat column */} |
| | 3974 | |
| | 3975 | {/* Plan sidebar */} |
| | 3976 | {planSidebarOpen ? ( |
| | 3977 | <PlanSidebar |
| | 3978 | activePlan={activePlan} |
| | 3979 | activeProposedPlan={activeProposedPlan} |
| | 3980 | markdownCwd={gitCwd ?? undefined} |
| | 3981 | workspaceRoot={activeProject?.cwd ?? undefined} |
| | 3982 | onClose={() => { |
| | 3983 | setPlanSidebarOpen(false); |
| | 3984 | // Track that the user explicitly dismissed for this turn so auto-open won't fight them. |
| | 3985 | const turnKey = activePlan?.turnId ?? activeProposedPlan?.turnId ?? null; |
| | 3986 | if (turnKey) { |
| | 3987 | planSidebarDismissedForTurnRef.current = turnKey; |
| | 3988 | } |
| | 3989 | }} |
| | 3990 | /> |
| | 3991 | ) : null} |
| | 3992 | </div>{/* end horizontal flex container */} |
| | 3993 | |
| 3918 | {isGitRepo && ( | 3994 | {isGitRepo && ( |
| 3919 | <BranchToolbar | 3995 | <BranchToolbar |
| 3920 | threadId={activeThread.id} | 3996 | threadId={activeThread.id} |
| ... | @@ -4274,61 +4350,13 @@ const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActi | ... | @@ -4274,61 +4350,13 @@ const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActi |
| 4274 | ); | 4350 | ); |
| 4275 | }); | 4351 | }); |
| 4276 | | 4352 | |
| 4277 | interface PlanModePanelProps { | | |
| 4278 | activePlan: ReturnType<typeof deriveActivePlanState>; | | |
| 4279 | } | | |
| 4280 | | | |
| 4281 | const PlanModePanel = memo(function PlanModePanel({ activePlan }: PlanModePanelProps) { | | |
| 4282 | if (!activePlan) return null; | | |
| 4283 | | | |
| 4284 | return ( | | |
| 4285 | <div className="pt-3 mx-auto max-w-3xl"> | | |
| 4286 | <div className="rounded-xl border border-border/70 bg-muted/30 p-4"> | | |
| 4287 | <div className="flex items-center gap-2"> | | |
| 4288 | <Badge variant="secondary">Plan</Badge> | | |
| 4289 | <span className="text-xs text-muted-foreground"> | | |
| 4290 | Updated {formatTimestamp(activePlan.createdAt)} | | |
| 4291 | </span> | | |
| 4292 | </div> | | |
| 4293 | {activePlan.explanation ? ( | | |
| 4294 | <p className="mt-2 text-sm text-muted-foreground">{activePlan.explanation}</p> | | |
| 4295 | ) : null} | | |
| 4296 | <div className="mt-3 space-y-2"> | | |
| 4297 | {activePlan.steps.map((step) => ( | | |
| 4298 | <div | | |
| 4299 | key={`${step.status}:${step.step}`} | | |
| 4300 | className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/80 px-3 py-2" | | |
| 4301 | > | | |
| 4302 | <Badge | | |
| 4303 | variant={ | | |
| 4304 | step.status === "completed" | | |
| 4305 | ? "default" | | |
| 4306 | : step.status === "inProgress" | | |
| 4307 | ? "secondary" | | |
| 4308 | : "outline" | | |
| 4309 | } | | |
| 4310 | > | | |
| 4311 | {step.status === "inProgress" | | |
| 4312 | ? "In progress" | | |
| 4313 | : step.status === "completed" | | |
| 4314 | ? "Done" | | |
| 4315 | : "Pending"} | | |
| 4316 | </Badge> | | |
| 4317 | <div className="min-w-0 flex-1 text-sm">{step.step}</div> | | |
| 4318 | </div> | | |
| 4319 | ))} | | |
| 4320 | </div> | | |
| 4321 | </div> | | |
| 4322 | </div> | | |
| 4323 | ); | | |
| 4324 | }); | | |
| 4325 | | | |
| 4326 | interface PendingUserInputPanelProps { | 4353 | interface PendingUserInputPanelProps { |
| 4327 | pendingUserInputs: PendingUserInput[]; | 4354 | pendingUserInputs: PendingUserInput[]; |
| 4328 | respondingRequestIds: ApprovalRequestId[]; | 4355 | respondingRequestIds: ApprovalRequestId[]; |
| 4329 | answers: Record<string, PendingUserInputDraftAnswer>; | 4356 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 4330 | questionIndex: number; | 4357 | questionIndex: number; |
| 4331 | onSelectOption: (questionId: string, optionLabel: string) => void; | 4358 | onSelectOption: (questionId: string, optionLabel: string) => void; |
| | 4359 | onAdvance: () => void; |
| 4332 | } | 4360 | } |
| 4333 | | 4361 | |
| 4334 | const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ | 4362 | const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ |
| ... | @@ -4337,6 +4365,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane | ... | @@ -4337,6 +4365,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane |
| 4337 | answers, | 4365 | answers, |
| 4338 | questionIndex, | 4366 | questionIndex, |
| 4339 | onSelectOption, | 4367 | onSelectOption, |
| | 4368 | onAdvance, |
| 4340 | }: PendingUserInputPanelProps) { | 4369 | }: PendingUserInputPanelProps) { |
| 4341 | if (pendingUserInputs.length === 0) return null; | 4370 | if (pendingUserInputs.length === 0) return null; |
| 4342 | const activePrompt = pendingUserInputs[0]; | 4371 | const activePrompt = pendingUserInputs[0]; |
| ... | @@ -4350,6 +4379,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane | ... | @@ -4350,6 +4379,7 @@ const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPane |
| 4350 | answers={answers} | 4379 | answers={answers} |
| 4351 | questionIndex={questionIndex} | 4380 | questionIndex={questionIndex} |
| 4352 | onSelectOption={onSelectOption} | 4381 | onSelectOption={onSelectOption} |
| | 4382 | onAdvance={onAdvance} |
| 4353 | /> | 4383 | /> |
| 4354 | ); | 4384 | ); |
| 4355 | }); | 4385 | }); |
| ... | @@ -4360,42 +4390,135 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | ... | @@ -4360,42 +4390,135 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 4360 | answers, | 4390 | answers, |
| 4361 | questionIndex, | 4391 | questionIndex, |
| 4362 | onSelectOption, | 4392 | onSelectOption, |
| | 4393 | onAdvance, |
| 4363 | }: { | 4394 | }: { |
| 4364 | prompt: PendingUserInput; | 4395 | prompt: PendingUserInput; |
| 4365 | isResponding: boolean; | 4396 | isResponding: boolean; |
| 4366 | answers: Record<string, PendingUserInputDraftAnswer>; | 4397 | answers: Record<string, PendingUserInputDraftAnswer>; |
| 4367 | questionIndex: number; | 4398 | questionIndex: number; |
| 4368 | onSelectOption: (questionId: string, optionLabel: string) => void; | 4399 | onSelectOption: (questionId: string, optionLabel: string) => void; |
| | 4400 | onAdvance: () => void; |
| 4369 | }) { | 4401 | }) { |
| 4370 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); | 4402 | const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); |
| 4371 | const activeQuestion = progress.activeQuestion; | 4403 | const activeQuestion = progress.activeQuestion; |
| | 4404 | const autoAdvanceTimerRef = useRef<number | null>(null); |
| | 4405 | |
| | 4406 | // Clear auto-advance timer on unmount |
| | 4407 | useEffect(() => { |
| | 4408 | return () => { |
| | 4409 | if (autoAdvanceTimerRef.current !== null) { |
| | 4410 | window.clearTimeout(autoAdvanceTimerRef.current); |
| | 4411 | } |
| | 4412 | }; |
| | 4413 | }, []); |
| | 4414 | |
| | 4415 | const selectOptionAndAutoAdvance = useCallback( |
| | 4416 | (questionId: string, optionLabel: string) => { |
| | 4417 | onSelectOption(questionId, optionLabel); |
| | 4418 | if (autoAdvanceTimerRef.current !== null) { |
| | 4419 | window.clearTimeout(autoAdvanceTimerRef.current); |
| | 4420 | } |
| | 4421 | autoAdvanceTimerRef.current = window.setTimeout(() => { |
| | 4422 | autoAdvanceTimerRef.current = null; |
| | 4423 | onAdvance(); |
| | 4424 | }, 200); |
| | 4425 | }, |
| | 4426 | [onSelectOption, onAdvance], |
| | 4427 | ); |
| | 4428 | |
| | 4429 | // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. |
| | 4430 | // Works even when the Lexical composer (contenteditable) has focus — the composer |
| | 4431 | // doubles as a custom-answer field during user input, and when it's empty the digit |
| | 4432 | // keys should pick options instead of typing into the editor. |
| | 4433 | useEffect(() => { |
| | 4434 | if (!activeQuestion || isResponding) return; |
| | 4435 | const handler = (event: globalThis.KeyboardEvent) => { |
| | 4436 | if (event.metaKey || event.ctrlKey || event.altKey) return; |
| | 4437 | const target = event.target; |
| | 4438 | if ( |
| | 4439 | target instanceof HTMLInputElement || |
| | 4440 | target instanceof HTMLTextAreaElement |
| | 4441 | ) { |
| | 4442 | return; |
| | 4443 | } |
| | 4444 | // If the user has started typing a custom answer in the contenteditable |
| | 4445 | // composer, let digit keys pass through so they can type numbers. |
| | 4446 | if (target instanceof HTMLElement && target.isContentEditable) { |
| | 4447 | const hasCustomText = progress.customAnswer.length > 0; |
| | 4448 | if (hasCustomText) return; |
| | 4449 | } |
| | 4450 | const digit = Number.parseInt(event.key, 10); |
| | 4451 | if (Number.isNaN(digit) || digit < 1 || digit > 9) return; |
| | 4452 | const optionIndex = digit - 1; |
| | 4453 | if (optionIndex >= activeQuestion.options.length) return; |
| | 4454 | const option = activeQuestion.options[optionIndex]; |
| | 4455 | if (!option) return; |
| | 4456 | event.preventDefault(); |
| | 4457 | selectOptionAndAutoAdvance(activeQuestion.id, option.label); |
| | 4458 | }; |
| | 4459 | document.addEventListener("keydown", handler); |
| | 4460 | return () => document.removeEventListener("keydown", handler); |
| | 4461 | }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]); |
| 4372 | | 4462 | |
| 4373 | if (!activeQuestion) { | 4463 | if (!activeQuestion) { |
| 4374 | return null; | 4464 | return null; |
| 4375 | } | 4465 | } |
| 4376 | | 4466 | |
| 4377 | return ( | 4467 | return ( |
| 4378 | <div className="px-4 py-4 sm:px-5"> | 4468 | <div className="px-4 py-3 sm:px-5"> |
| 4379 | <div className="flex gap-2"> | 4469 | <div className="flex items-center gap-3"> |
| 4380 | <span className="uppercase text-sm tracking-[0.2em]"> | 4470 | <div className="flex items-center gap-2"> |
| 4381 | {questionIndex + 1}/{prompt.questions.length} {activeQuestion.header} | 4471 | {prompt.questions.length > 1 ? ( |
| 4382 | </span> | 4472 | <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-[10px] font-medium tabular-nums text-muted-foreground/60"> |
| 4383 | <div className="text-sm font-medium">{activeQuestion.question}</div> | 4473 | {questionIndex + 1}/{prompt.questions.length} |
| | 4474 | </span> |
| | 4475 | ) : null} |
| | 4476 | <span className="text-[11px] font-semibold tracking-widest text-muted-foreground/50 uppercase"> |
| | 4477 | {activeQuestion.header} |
| | 4478 | </span> |
| | 4479 | </div> |
| 4384 | </div> | 4480 | </div> |
| 4385 | <div className="mt-3 flex flex-wrap gap-2"> | 4481 | <p className="mt-1.5 text-sm text-foreground/90">{activeQuestion.question}</p> |
| 4386 | {activeQuestion.options.map((option) => { | 4482 | <div className="mt-3 space-y-1"> |
| | 4483 | {activeQuestion.options.map((option, index) => { |
| 4387 | const isSelected = progress.selectedOptionLabel === option.label; | 4484 | const isSelected = progress.selectedOptionLabel === option.label; |
| | 4485 | const shortcutKey = index < 9 ? index + 1 : null; |
| 4388 | return ( | 4486 | return ( |
| 4389 | <Button | 4487 | <button |
| 4390 | key={`${activeQuestion.id}:${option.label}`} | 4488 | key={`${activeQuestion.id}:${option.label}`} |
| 4391 | size="sm" | 4489 | type="button" |
| 4392 | variant={isSelected ? "default" : "outline"} | | |
| 4393 | disabled={isResponding} | 4490 | disabled={isResponding} |
| 4394 | onClick={() => onSelectOption(activeQuestion.id, option.label)} | 4491 | onClick={() => selectOptionAndAutoAdvance(activeQuestion.id, option.label)} |
| 4395 | title={option.description} | 4492 | className={cn( |
| | 4493 | "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-all duration-150", |
| | 4494 | isSelected |
| | 4495 | ? "border-blue-500/40 bg-blue-500/8 text-foreground" |
| | 4496 | : "border-transparent bg-muted/20 text-foreground/80 hover:bg-muted/40 hover:border-border/40", |
| | 4497 | isResponding && "opacity-50 cursor-not-allowed", |
| | 4498 | )} |
| 4396 | > | 4499 | > |
| 4397 | {option.label} | 4500 | {shortcutKey !== null ? ( |
| 4398 | </Button> | 4501 | <kbd |
| | 4502 | className={cn( |
| | 4503 | "flex size-5 shrink-0 items-center justify-center rounded text-[11px] font-medium tabular-nums transition-colors duration-150", |
| | 4504 | isSelected |
| | 4505 | ? "bg-blue-500/20 text-blue-400" |
| | 4506 | : "bg-muted/40 text-muted-foreground/50 group-hover:bg-muted/60 group-hover:text-muted-foreground/70", |
| | 4507 | )} |
| | 4508 | > |
| | 4509 | {shortcutKey} |
| | 4510 | </kbd> |
| | 4511 | ) : null} |
| | 4512 | <div className="min-w-0 flex-1"> |
| | 4513 | <span className="text-sm font-medium">{option.label}</span> |
| | 4514 | {option.description && option.description !== option.label ? ( |
| | 4515 | <span className="ml-2 text-xs text-muted-foreground/50">{option.description}</span> |
| | 4516 | ) : null} |
| | 4517 | </div> |
| | 4518 | {isSelected ? ( |
| | 4519 | <CheckIcon className="size-3.5 shrink-0 text-blue-400" /> |
| | 4520 | ) : null} |
| | 4521 | </button> |
| 4399 | ); | 4522 | ); |
| 4400 | })} | 4523 | })} |
| 4401 | </div> | 4524 | </div> |
| ... | @@ -4609,7 +4732,7 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -4609,7 +4732,7 @@ const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 4609 | const saveContents = normalizePlanMarkdownForExport(planMarkdown); | 4732 | const saveContents = normalizePlanMarkdownForExport(planMarkdown); |
| 4610 | | 4733 | |
| 4611 | const handleDownload = () => { | 4734 | const handleDownload = () => { |
| 4612 | downloadTextFile(downloadFilename, saveContents); | 4735 | downloadPlanAsTextFile(downloadFilename, saveContents); |
| 4613 | }; | 4736 | }; |
| 4614 | | 4737 | |
| 4615 | const openSaveDialog = () => { | 4738 | const openSaveDialog = () => { |