authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-25 10:09:06-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
logda6706777b69500e7544613e1db91371bee94ac0
tree338ab11f41b620c0c529924dad3c906ccd9fe206
parentce96ad0b61b58db07f6cc197464032b11b590890

Refine Electron header drag regions and diff panel layout

- Restructure Chat, DiffPanel, and Sidebar headers for cleaner Electron drag/no-drag behavior - Keep turn-strip controls interactive while preserving draggable titlebar regions - Lower inline diff panel minimum width from 28rem to 26rem for tighter layouts

4 files changed, 160 insertions(+), 138 deletions(-)

apps/web/src/components/ChatView.tsx+1-1
......@@ -1698,7 +1698,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
16981698 <header
16991699 className={cn(
17001700 "border-b border-border px-3 sm:px-5",
1701 isElectron ? "drag-region h-[52px]" : "py-2 sm:py-3",
1701 isElectron ? "drag-region flex h-[52px] items-center" : "py-2 sm:py-3",
17021702 )}
17031703 >
17041704 <ChatHeader
apps/web/src/components/DiffPanel.tsx+110-104
......@@ -298,133 +298,139 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
298298 }, [selectedTurn?.turnId, selectedTurnId]);
299299
300300 const shouldUseDragRegion = isElectron && mode !== "sheet";
301
302 return (
303 <div
304 className={cn(
305 "flex h-full min-w-0 flex-col bg-card",
306 mode === "inline"
307 ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border"
308 : "w-full",
309 )}
310 >
311 <div className="border-b border-border">
312 <div
301 const headerRow = (
302 <>
303 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
304 {canScrollTurnStripLeft && (
305 <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-linear-to-r from-card to-transparent" />
306 )}
307 {canScrollTurnStripRight && (
308 <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-linear-to-l from-card to-transparent" />
309 )}
310 <button
311 type="button"
313312 className={cn(
314 "flex h-12 items-center justify-between gap-2 px-4",
315 shouldUseDragRegion ? "drag-region" : undefined,
313 "absolute left-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",
314 canScrollTurnStripLeft
315 ? "border-border/70 hover:border-border hover:text-foreground"
316 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
316317 )}
318 onClick={() => scrollTurnStripBy(-180)}
319 disabled={!canScrollTurnStripLeft}
320 aria-label="Scroll turn list left"
317321 >
318 <div className="relative min-w-0 flex-1 [-webkit-app-region:no-drag]">
319 {canScrollTurnStripLeft && (
320 <div className="pointer-events-none absolute inset-y-0 left-8 z-10 w-7 bg-linear-to-r from-card to-transparent" />
321 )}
322 {canScrollTurnStripRight && (
323 <div className="pointer-events-none absolute inset-y-0 right-8 z-10 w-7 bg-linear-to-l from-card to-transparent" />
322 <ChevronLeftIcon className="size-3.5" />
323 </button>
324 <button
325 type="button"
326 className={cn(
327 "absolute right-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",
328 canScrollTurnStripRight
329 ? "border-border/70 hover:border-border hover:text-foreground"
330 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
324331 )}
332 onClick={() => scrollTurnStripBy(180)}
333 disabled={!canScrollTurnStripRight}
334 aria-label="Scroll turn list right"
335 >
336 <ChevronRightIcon className="size-3.5" />
337 </button>
338 <div
339 ref={turnStripRef}
340 className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
341 >
325342 <button
326343 type="button"
327 className={cn(
328 "absolute left-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",
329 canScrollTurnStripLeft
330 ? "border-border/70 hover:border-border hover:text-foreground"
331 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
332 )}
333 onClick={() => scrollTurnStripBy(-180)}
334 disabled={!canScrollTurnStripLeft}
335 aria-label="Scroll turn list left"
336 >
337 <ChevronLeftIcon className="size-3.5" />
338 </button>
339 <button
340 type="button"
341 className={cn(
342 "absolute right-0 top-1/2 z-20 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md border bg-background/90 text-muted-foreground transition-colors",
343 canScrollTurnStripRight
344 ? "border-border/70 hover:border-border hover:text-foreground"
345 : "cursor-not-allowed border-border/40 text-muted-foreground/40",
346 )}
347 onClick={() => scrollTurnStripBy(180)}
348 disabled={!canScrollTurnStripRight}
349 aria-label="Scroll turn list right"
344 className="shrink-0 rounded-md"
345 onClick={selectWholeConversation}
346 data-turn-chip-selected={selectedTurnId === null}
350347 >
351 <ChevronRightIcon className="size-3.5" />
348 <div
349 className={cn(
350 "rounded-md border px-2 py-1 text-left transition-colors",
351 selectedTurnId === null
352 ? "border-border bg-accent text-accent-foreground"
353 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
354 )}
355 >
356 <div className="text-[10px] leading-tight font-medium">All turns</div>
357 </div>
352358 </button>
353 <div
354 ref={turnStripRef}
355 className="turn-chip-strip flex gap-1 overflow-x-auto px-8 py-0.5"
356 >
359 {orderedTurnDiffSummaries.map((summary) => (
357360 <button
361 key={summary.turnId}
358362 type="button"
359363 className="shrink-0 rounded-md"
360 onClick={selectWholeConversation}
361 data-turn-chip-selected={selectedTurnId === null}
364 onClick={() => selectTurn(summary.turnId)}
365 title={summary.turnId}
366 data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}
362367 >
363368 <div
364369 className={cn(
365370 "rounded-md border px-2 py-1 text-left transition-colors",
366 selectedTurnId === null
371 summary.turnId === selectedTurn?.turnId
367372 ? "border-border bg-accent text-accent-foreground"
368373 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
369374 )}
370375 >
371 <div className="text-[10px] leading-tight font-medium">All turns</div>
376 <div className="flex items-center gap-1">
377 <span className="text-[10px] leading-tight font-medium">
378 Turn{" "}
379 {summary.checkpointTurnCount ??
380 inferredCheckpointTurnCountByTurnId[summary.turnId] ??
381 "?"}
382 </span>
383 <span className="text-[9px] leading-tight opacity-70">
384 {formatTurnChipTimestamp(summary.completedAt)}
385 </span>
386 </div>
372387 </div>
373388 </button>
374 {orderedTurnDiffSummaries.map((summary) => (
375 <button
376 key={summary.turnId}
377 type="button"
378 className="shrink-0 rounded-md"
379 onClick={() => selectTurn(summary.turnId)}
380 title={summary.turnId}
381 data-turn-chip-selected={summary.turnId === selectedTurn?.turnId}
382 >
383 <div
384 className={cn(
385 "rounded-md border px-2 py-1 text-left transition-colors",
386 summary.turnId === selectedTurn?.turnId
387 ? "border-border bg-accent text-accent-foreground"
388 : "border-border/70 bg-background/70 text-muted-foreground/80 hover:border-border hover:text-foreground/80",
389 )}
390 >
391 <div className="flex items-center gap-1">
392 <span className="text-[10px] leading-tight font-medium">
393 Turn{" "}
394 {summary.checkpointTurnCount ??
395 inferredCheckpointTurnCountByTurnId[summary.turnId] ??
396 "?"}
397 </span>
398 <span className="text-[9px] leading-tight opacity-70">
399 {formatTurnChipTimestamp(summary.completedAt)}
400 </span>
401 </div>
402 </div>
403 </button>
404 ))}
405 </div>
406 </div>
407 <ToggleGroup
408 className="shrink-0 [-webkit-app-region:no-drag]"
409 variant="outline"
410 size="xs"
411 value={[diffRenderMode]}
412 onValueChange={(value) => {
413 const next = value[0];
414 if (next === "stacked" || next === "split") {
415 setDiffRenderMode(next);
416 }
417 }}
418 >
419 <Toggle aria-label="Stacked diff view" value="stacked">
420 <Rows3Icon className="size-3" />
421 </Toggle>
422 <Toggle aria-label="Split diff view" value="split">
423 <Columns2Icon className="size-3" />
424 </Toggle>
425 </ToggleGroup>
389 ))}
426390 </div>
427391 </div>
392 <ToggleGroup
393 className="shrink-0 [-webkit-app-region:no-drag]"
394 variant="outline"
395 size="xs"
396 value={[diffRenderMode]}
397 onValueChange={(value) => {
398 const next = value[0];
399 if (next === "stacked" || next === "split") {
400 setDiffRenderMode(next);
401 }
402 }}
403 >
404 <Toggle aria-label="Stacked diff view" value="stacked">
405 <Rows3Icon className="size-3" />
406 </Toggle>
407 <Toggle aria-label="Split diff view" value="split">
408 <Columns2Icon className="size-3" />
409 </Toggle>
410 </ToggleGroup>
411 </>
412 );
413 const headerRowClassName = cn(
414 "flex items-center justify-between gap-2 px-4",
415 shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12",
416 );
417
418 return (
419 <div
420 className={cn(
421 "flex h-full min-w-0 flex-col bg-card",
422 mode === "inline"
423 ? "w-[42vw] min-w-[360px] max-w-[560px] shrink-0 border-l border-border"
424 : "w-full",
425 )}
426 >
427 {shouldUseDragRegion ? (
428 <div className={headerRowClassName}>{headerRow}</div>
429 ) : (
430 <div className="border-b border-border">
431 <div className={headerRowClassName}>{headerRow}</div>
432 </div>
433 )}
428434
429435 {!activeThread ? (
430436 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
apps/web/src/components/Sidebar.tsx+48-32
......@@ -467,40 +467,56 @@ export default function Sidebar() {
467467 };
468468 }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
469469
470 const onCreateThreadClick = () => {
471 if (state.projects.length === 0) {
472 setAddingProject(true);
473 return;
474 }
475 const firstProject = state.projects[0];
476 if (firstProject) void handleNewThread(firstProject.id);
477 };
478
479 const wordmark = (
480 <div className={`flex items-center gap-2 ${isElectron ? "-translate-y-px" : ""}`}>
481 <SidebarTrigger className="shrink-0 md:hidden" />
482 <div className="flex min-w-0 flex-1 items-center gap-1">
483 <T3Wordmark />
484 <span className="truncate text-base font-medium tracking-tight text-muted-foreground">
485 Code
486 </span>
487 <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60">
488 {APP_STAGE_LABEL}
489 </span>
490 </div>
491 </div>
492 );
493
494 const newThreadButton = (
495 <button
496 type="button"
497 className="flex w-full items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent"
498 onClick={onCreateThreadClick}
499 >
500 <span className="text-foreground">+</span>
501 New thread
502 </button>
503 );
504
470505 return (
471506 <>
472 <SidebarHeader
473 className={`gap-3 ${isElectron ? "drag-region h-[52px] px-4 pl-[88px]" : "px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3"}`}
474 >
475 <div className="flex items-center gap-2">
476 <SidebarTrigger className="shrink-0 md:hidden" />
477 <div className="flex min-w-0 flex-1 items-center gap-1">
478 <T3Wordmark />
479 <span className="truncate text-base font-medium tracking-tight text-muted-foreground">
480 Code
481 </span>
482 <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60">
483 {APP_STAGE_LABEL}
484 </span>
485 </div>
486 </div>
487
488 <button
489 type="button"
490 className="flex w-full items-center gap-2 rounded-lg border border-border bg-secondary px-3 py-2 text-xs text-muted-foreground transition-colors duration-150 hover:bg-accent"
491 onClick={() => {
492 if (state.projects.length === 0) {
493 setAddingProject(true);
494 return;
495 }
496 const firstProject = state.projects[0];
497 if (firstProject) void handleNewThread(firstProject.id);
498 }}
499 >
500 <span className="text-foreground">+</span>
501 New thread
502 </button>
503 </SidebarHeader>
507 {isElectron ? (
508 <>
509 <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[88px]">
510 {wordmark}
511 </SidebarHeader>
512 <div className="px-4 pb-2">{newThreadButton}</div>
513 </>
514 ) : (
515 <SidebarHeader className="gap-3 px-3 py-2 sm:gap-2.5 sm:px-4 sm:py-3">
516 {wordmark}
517 {newThreadButton}
518 </SidebarHeader>
519 )}
504520
505521 <SidebarContent className="gap-0">
506522 <SidebarGroup className="px-2 py-2">
apps/web/src/routes/_chat.$threadId.tsx+1-1
......@@ -12,7 +12,7 @@ const DiffPanel = lazy(() => import("../components/DiffPanel"));
1212const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
1313const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width";
1414const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)";
15const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 28 * 16;
15const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16;
1616
1717const DiffPanelSheet = (props: {
1818 children: ReactNode;