authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-15 16:21:06-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-15 23:21:06+00:00
log71ea5fa1abcd7a3156a5fe003596b724b9fdc68b
tree703cb0509a516bbcca3c48ee8a614dcefe4ba53d
parent9d5e632d4d41ca3f6932506255134dc297ba0278
signature Signed by PGP key B5690EEEBB952194

Show disabled reasons for unavailable right panel surfaces (#3093)


2 files changed, 90 insertions(+), 26 deletions(-)

apps/web/src/components/ChatView.browser.tsx-8
......@@ -2129,10 +2129,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
21292129 () => document.querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]'),
21302130 "Unable to find right panel toggle.",
21312131 );
2132 const headerActions = await waitForElement(
2133 () => document.querySelector<HTMLElement>("[data-chat-header-actions]"),
2134 "Unable to find chat header actions.",
2135 );
21362132 const chatHeader = await waitForElement(
21372133 () => document.querySelector<HTMLElement>("[data-chat-header]"),
21382134 "Unable to find chat header.",
......@@ -2163,10 +2159,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
21632159 true,
21642160 );
21652161 expect(initialRightPanelRect.left - initialTerminalRect.right).toBe(4);
2166 expect(
2167 initialTerminalRect.left -
2168 (headerActions.lastElementChild?.getBoundingClientRect().right ?? Number.NaN),
2169 ).toBe(4);
21702162
21712163 document.documentElement.classList.add("wco");
21722164 expect(panelLayoutControls.getBoundingClientRect().height).toBe(52);
apps/web/src/components/RightPanelTabs.tsx+90-18
......@@ -1,7 +1,7 @@
11import type { PreviewSessionSnapshot } from "@t3tools/contracts";
22import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
33import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react";
4import { type ReactNode, useEffect, useRef, useState } from "react";
4import { type ReactElement, type ReactNode, useEffect, useRef, useState } from "react";
55
66import { isElectron } from "~/env";
77import type { RightPanelSurface } from "~/rightPanelStore";
......@@ -35,6 +35,40 @@ interface RightPanelTabsProps {
3535 children: ReactNode;
3636}
3737
38const SURFACE_DISABLED_REASONS = {
39 browser: "Browser previews are only available in the T3 Code desktop app.",
40 files: "Files are only available when a project is open.",
41 diff: "Diff is only available for server threads in Git repositories.",
42} as const;
43
44function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {
45 return (
46 <Tooltip>
47 <TooltipTrigger render={props.trigger} />
48 <TooltipPopup side="top">{props.reason}</TooltipPopup>
49 </Tooltip>
50 );
51}
52
53function SurfaceMenuItem(props: {
54 available: boolean;
55 disabledReason?: string;
56 onClick: () => void;
57 children: ReactNode;
58}) {
59 const item = (
60 <MenuItem
61 className={!props.available ? "data-disabled:pointer-events-auto" : undefined}
62 onClick={props.onClick}
63 disabled={!props.available}
64 >
65 {props.children}
66 </MenuItem>
67 );
68 if (props.available || !props.disabledReason) return item;
69 return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />;
70}
71
3872function RightPanelEmptyState(props: {
3973 onAddBrowser: () => void;
4074 onAddTerminal: () => void;
......@@ -50,6 +84,7 @@ function RightPanelEmptyState(props: {
5084 description: "Open a local app or URL.",
5185 icon: Globe2,
5286 available: props.browserAvailable,
87 disabledReason: SURFACE_DISABLED_REASONS.browser,
5388 onClick: props.onAddBrowser,
5489 },
5590 {
......@@ -57,6 +92,7 @@ function RightPanelEmptyState(props: {
5792 description: "Start a shell in this workspace.",
5893 icon: TerminalSquare,
5994 available: true,
95 disabledReason: null,
6096 onClick: props.onAddTerminal,
6197 },
6298 {
......@@ -64,6 +100,7 @@ function RightPanelEmptyState(props: {
64100 description: "Browse and read workspace files.",
65101 icon: Files,
66102 available: props.filesAvailable,
103 disabledReason: SURFACE_DISABLED_REASONS.files,
67104 onClick: props.onAddFiles,
68105 },
69106 {
......@@ -71,6 +108,7 @@ function RightPanelEmptyState(props: {
71108 description: "Review changes in this thread.",
72109 icon: FileDiff,
73110 available: props.diffAvailable,
111 disabledReason: SURFACE_DISABLED_REASONS.diff,
74112 onClick: props.onAddDiff,
75113 },
76114 ] as const;
......@@ -84,24 +122,46 @@ function RightPanelEmptyState(props: {
84122 Choose what to show in the right panel.
85123 </p>
86124 </div>
87 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
125 <div className="grid grid-cols-2 gap-2">
88126 {actions.map((action) => {
89127 const Icon = action.icon;
90 return (
91 <button
92 key={action.label}
93 type="button"
94 disabled={!action.available}
95 onClick={action.onClick}
96 className="flex min-h-28 flex-col items-start rounded-lg border border-border/80 bg-card/40 p-4 text-left transition hover:border-border hover:bg-accent/60 disabled:cursor-not-allowed disabled:opacity-40"
97 >
128 const content = (
129 <>
98130 <Icon className="mb-3 size-5" />
99131 <span className="text-sm font-medium">{action.label}</span>
100132 <span className="mt-1 text-xs leading-relaxed text-muted-foreground">
101133 {action.description}
102134 </span>
135 </>
136 );
137 if (action.available) {
138 return (
139 <button
140 key={action.label}
141 type="button"
142 onClick={action.onClick}
143 className="flex min-h-28 w-full flex-col items-start rounded-lg border border-border/80 bg-card/40 p-4 text-left transition hover:border-border hover:bg-accent/60"
144 >
145 {content}
146 </button>
147 );
148 }
149 const disabledCard = (
150 <button
151 type="button"
152 className="flex min-h-28 w-full cursor-not-allowed flex-col items-start rounded-lg border border-border/80 bg-card/40 p-4 text-left opacity-40"
153 aria-disabled="true"
154 >
155 {content}
103156 </button>
104157 );
158 return (
159 <DisabledReasonTooltip
160 key={action.label}
161 reason={action.disabledReason}
162 trigger={disabledCard}
163 />
164 );
105165 })}
106166 </div>
107167 </div>
......@@ -290,22 +350,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
290350 <Plus className="size-4" />
291351 </MenuTrigger>
292352 <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44">
293 <MenuItem onClick={props.onAddBrowser} disabled={!props.browserAvailable}>
353 <SurfaceMenuItem
354 available={props.browserAvailable}
355 disabledReason={SURFACE_DISABLED_REASONS.browser}
356 onClick={props.onAddBrowser}
357 >
294358 <Globe2 />
295359 Browser
296 </MenuItem>
297 <MenuItem onClick={props.onAddTerminal}>
360 </SurfaceMenuItem>
361 <SurfaceMenuItem available onClick={props.onAddTerminal}>
298362 <TerminalSquare />
299363 Terminal
300 </MenuItem>
301 <MenuItem onClick={props.onAddFiles} disabled={!props.filesAvailable}>
364 </SurfaceMenuItem>
365 <SurfaceMenuItem
366 available={props.filesAvailable}
367 disabledReason={SURFACE_DISABLED_REASONS.files}
368 onClick={props.onAddFiles}
369 >
302370 <Files />
303371 Files
304 </MenuItem>
305 <MenuItem onClick={props.onAddDiff} disabled={!props.diffAvailable}>
372 </SurfaceMenuItem>
373 <SurfaceMenuItem
374 available={props.diffAvailable}
375 disabledReason={SURFACE_DISABLED_REASONS.diff}
376 onClick={props.onAddDiff}
377 >
306378 <FileDiff />
307379 Diff
308 </MenuItem>
380 </SurfaceMenuItem>
309381 </MenuPopup>
310382 </Menu>
311383 ) : null}