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)", () => {...@@ -2129,10 +2129,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
2129 () => document.querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]'),2129 () => document.querySelector<HTMLButtonElement>('button[aria-label="Toggle right panel"]'),
2130 "Unable to find right panel toggle.",2130 "Unable to find right panel toggle.",
2131 );2131 );
2132 const headerActions = await waitForElement(
2133 () => document.querySelector<HTMLElement>("[data-chat-header-actions]"),
2134 "Unable to find chat header actions.",
2135 );
2136 const chatHeader = await waitForElement(2132 const chatHeader = await waitForElement(
2137 () => document.querySelector<HTMLElement>("[data-chat-header]"),2133 () => document.querySelector<HTMLElement>("[data-chat-header]"),
2138 "Unable to find chat header.",2134 "Unable to find chat header.",
...@@ -2163,10 +2159,6 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -2163,10 +2159,6 @@ describe("ChatView timeline estimator parity (full app)", () => {
2163 true,2159 true,
2164 );2160 );
2165 expect(initialRightPanelRect.left - initialTerminalRect.right).toBe(4);2161 expect(initialRightPanelRect.left - initialTerminalRect.right).toBe(4);
2166 expect(
2167 initialTerminalRect.left -
2168 (headerActions.lastElementChild?.getBoundingClientRect().right ?? Number.NaN),
2169 ).toBe(4);
21702162
2171 document.documentElement.classList.add("wco");2163 document.documentElement.classList.add("wco");
2172 expect(panelLayoutControls.getBoundingClientRect().height).toBe(52);2164 expect(panelLayoutControls.getBoundingClientRect().height).toBe(52);
apps/web/src/components/RightPanelTabs.tsx+90-18
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { PreviewSessionSnapshot } from "@t3tools/contracts";1import type { PreviewSessionSnapshot } from "@t3tools/contracts";
2import { getTerminalLabel } from "@t3tools/shared/terminalLabels";2import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
3import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react";3import { 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
6import { isElectron } from "~/env";6import { isElectron } from "~/env";
7import type { RightPanelSurface } from "~/rightPanelStore";7import type { RightPanelSurface } from "~/rightPanelStore";
...@@ -35,6 +35,40 @@ interface RightPanelTabsProps {...@@ -35,6 +35,40 @@ interface RightPanelTabsProps {
35 children: ReactNode;35 children: ReactNode;
36}36}
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
38function RightPanelEmptyState(props: {72function RightPanelEmptyState(props: {
39 onAddBrowser: () => void;73 onAddBrowser: () => void;
40 onAddTerminal: () => void;74 onAddTerminal: () => void;
...@@ -50,6 +84,7 @@ function RightPanelEmptyState(props: {...@@ -50,6 +84,7 @@ function RightPanelEmptyState(props: {
50 description: "Open a local app or URL.",84 description: "Open a local app or URL.",
51 icon: Globe2,85 icon: Globe2,
52 available: props.browserAvailable,86 available: props.browserAvailable,
87 disabledReason: SURFACE_DISABLED_REASONS.browser,
53 onClick: props.onAddBrowser,88 onClick: props.onAddBrowser,
54 },89 },
55 {90 {
...@@ -57,6 +92,7 @@ function RightPanelEmptyState(props: {...@@ -57,6 +92,7 @@ function RightPanelEmptyState(props: {
57 description: "Start a shell in this workspace.",92 description: "Start a shell in this workspace.",
58 icon: TerminalSquare,93 icon: TerminalSquare,
59 available: true,94 available: true,
95 disabledReason: null,
60 onClick: props.onAddTerminal,96 onClick: props.onAddTerminal,
61 },97 },
62 {98 {
...@@ -64,6 +100,7 @@ function RightPanelEmptyState(props: {...@@ -64,6 +100,7 @@ function RightPanelEmptyState(props: {
64 description: "Browse and read workspace files.",100 description: "Browse and read workspace files.",
65 icon: Files,101 icon: Files,
66 available: props.filesAvailable,102 available: props.filesAvailable,
103 disabledReason: SURFACE_DISABLED_REASONS.files,
67 onClick: props.onAddFiles,104 onClick: props.onAddFiles,
68 },105 },
69 {106 {
...@@ -71,6 +108,7 @@ function RightPanelEmptyState(props: {...@@ -71,6 +108,7 @@ function RightPanelEmptyState(props: {
71 description: "Review changes in this thread.",108 description: "Review changes in this thread.",
72 icon: FileDiff,109 icon: FileDiff,
73 available: props.diffAvailable,110 available: props.diffAvailable,
111 disabledReason: SURFACE_DISABLED_REASONS.diff,
74 onClick: props.onAddDiff,112 onClick: props.onAddDiff,
75 },113 },
76 ] as const;114 ] as const;
...@@ -84,24 +122,46 @@ function RightPanelEmptyState(props: {...@@ -84,24 +122,46 @@ function RightPanelEmptyState(props: {
84 Choose what to show in the right panel.122 Choose what to show in the right panel.
85 </p>123 </p>
86 </div>124 </div>
87 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">125 <div className="grid grid-cols-2 gap-2">
88 {actions.map((action) => {126 {actions.map((action) => {
89 const Icon = action.icon;127 const Icon = action.icon;
90 return (128 const content = (
91 <button129 <>
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 >
98 <Icon className="mb-3 size-5" />130 <Icon className="mb-3 size-5" />
99 <span className="text-sm font-medium">{action.label}</span>131 <span className="text-sm font-medium">{action.label}</span>
100 <span className="mt-1 text-xs leading-relaxed text-muted-foreground">132 <span className="mt-1 text-xs leading-relaxed text-muted-foreground">
101 {action.description}133 {action.description}
102 </span>134 </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}
103 </button>156 </button>
104 );157 );
158 return (
159 <DisabledReasonTooltip
160 key={action.label}
161 reason={action.disabledReason}
162 trigger={disabledCard}
163 />
164 );
105 })}165 })}
106 </div>166 </div>
107 </div>167 </div>
...@@ -290,22 +350,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -290,22 +350,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
290 <Plus className="size-4" />350 <Plus className="size-4" />
291 </MenuTrigger>351 </MenuTrigger>
292 <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44">352 <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 >
294 <Globe2 />358 <Globe2 />
295 Browser359 Browser
296 </MenuItem>360 </SurfaceMenuItem>
297 <MenuItem onClick={props.onAddTerminal}>361 <SurfaceMenuItem available onClick={props.onAddTerminal}>
298 <TerminalSquare />362 <TerminalSquare />
299 Terminal363 Terminal
300 </MenuItem>364 </SurfaceMenuItem>
301 <MenuItem onClick={props.onAddFiles} disabled={!props.filesAvailable}>365 <SurfaceMenuItem
366 available={props.filesAvailable}
367 disabledReason={SURFACE_DISABLED_REASONS.files}
368 onClick={props.onAddFiles}
369 >
302 <Files />370 <Files />
303 Files371 Files
304 </MenuItem>372 </SurfaceMenuItem>
305 <MenuItem onClick={props.onAddDiff} disabled={!props.diffAvailable}>373 <SurfaceMenuItem
374 available={props.diffAvailable}
375 disabledReason={SURFACE_DISABLED_REASONS.diff}
376 onClick={props.onAddDiff}
377 >
306 <FileDiff />378 <FileDiff />
307 Diff379 Diff
308 </MenuItem>380 </SurfaceMenuItem>
309 </MenuPopup>381 </MenuPopup>
310 </Menu>382 </Menu>
311 ) : null}383 ) : null}