authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-09-20 20:01:29+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-20 14:31:29+00:00
logc14f6015bfe479d313355cb234af1a5c16dbb15f
treede94f5fba1002627d60fd94d847fd9d502eb5a75
parent0ff87f251dafb32703d5f531e7b248ad0a581ee2
signature Signed by PGP key B5690EEEBB952194

fix(web): keep narrow chat headers readable and aligned (#12453)


7 files changed, 563 insertions(+), 229 deletions(-)

apps/web/src/components/GitActionsControl.tsx+175-106
...@@ -79,7 +79,16 @@ import {...@@ -79,7 +79,16 @@ import {
79} from "~/components/ui/dialog";79} from "~/components/ui/dialog";
80import { Group, GroupSeparator } from "~/components/ui/group";80import { Group, GroupSeparator } from "~/components/ui/group";
81import { Input } from "~/components/ui/input";81import { Input } from "~/components/ui/input";
82import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";82import {
83 Menu,
84 MenuItem,
85 MenuItemLabel,
86 MenuPopup,
87 MenuSub,
88 MenuSubTrigger,
89 MenuSubPopup,
90 MenuTrigger,
91} from "~/components/ui/menu";
83import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";92import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
84import { ScrollArea } from "~/components/ui/scroll-area";93import { ScrollArea } from "~/components/ui/scroll-area";
85import { Textarea } from "~/components/ui/textarea";94import { Textarea } from "~/components/ui/textarea";
...@@ -108,6 +117,7 @@ import { useOpenLink } from "~/browser/useOpenLink";...@@ -108,6 +117,7 @@ import { useOpenLink } from "~/browser/useOpenLink";
108import { useOpenPrLink } from "~/lib/openPullRequestLink";117import { useOpenPrLink } from "~/lib/openPullRequestLink";
109118
110interface GitActionsControlProps {119interface GitActionsControlProps {
120 presentation?: "toolbar" | "menu";
111 gitCwd: string | null;121 gitCwd: string | null;
112 activeThreadRef: ScopedThreadRef | null;122 activeThreadRef: ScopedThreadRef | null;
113 draftId?: DraftId;123 draftId?: DraftId;
...@@ -374,24 +384,25 @@ function GitActionItemIcon({...@@ -374,24 +384,25 @@ function GitActionItemIcon({
374function GitQuickActionIcon({384function GitQuickActionIcon({
375 quickAction,385 quickAction,
376 SourceControlIcon,386 SourceControlIcon,
387 className = "size-3.5",
377}: {388}: {
378 quickAction: GitQuickAction;389 quickAction: GitQuickAction;
390 className?: string;
379 SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];391 SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];
380}) {392}) {
381 const iconClassName = "size-3.5";393 if (quickAction.kind === "open_pr") return <SourceControlIcon className={className} />;
382 if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />;394 if (quickAction.kind === "open_publish") return <CloudUploadIcon className={className} />;
383 if (quickAction.kind === "open_publish") return <CloudUploadIcon className={iconClassName} />;395 if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={className} />;
384 if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={iconClassName} />;
385 if (quickAction.kind === "run_action") {396 if (quickAction.kind === "run_action") {
386 if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />;397 if (quickAction.action === "commit") return <GitCommitIcon className={className} />;
387 if (quickAction.action === "push" || quickAction.action === "commit_push") {398 if (quickAction.action === "push" || quickAction.action === "commit_push") {
388 return <CloudUploadIcon className={iconClassName} />;399 return <CloudUploadIcon className={className} />;
389 }400 }
390 return <SourceControlIcon className={iconClassName} />;401 return <SourceControlIcon className={className} />;
391 }402 }
392 if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />;403 if (quickAction.label === "Commit") return <GitCommitIcon className={className} />;
393 if (quickAction.label === "Push") return <CloudUploadIcon className={iconClassName} />;404 if (quickAction.label === "Push") return <CloudUploadIcon className={className} />;
394 return <InfoIcon className={iconClassName} />;405 return <InfoIcon className={className} />;
395}406}
396407
397interface PublishRepositoryDialogProps {408interface PublishRepositoryDialogProps {
...@@ -944,6 +955,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -944,6 +955,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
944}955}
945956
946export default function GitActionsControl({957export default function GitActionsControl({
958 presentation = "toolbar",
947 gitCwd,959 gitCwd,
948 activeThreadRef,960 activeThreadRef,
949 draftId,961 draftId,
...@@ -1632,33 +1644,161 @@ export default function GitActionsControl({...@@ -1632,33 +1644,161 @@ export default function GitActionsControl({
16321644
1633 const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote;1645 const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote;
16341646
1635 if (!gitCwd) return null;1647 const initializeGit = () => {
16361648 void (async () => {
1637 return (1649 const result = await initAction.run();
1650 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
1651 return;
1652 }
1653 const error = squashAtomCommandFailure(result);
1654 toastManager.add(
1655 stackedThreadToast({
1656 type: "error",
1657 title: "Git initialization failed",
1658 description: error instanceof Error ? error.message : "An error occurred.",
1659 ...(threadToastData !== undefined ? { data: threadToastData } : {}),
1660 }),
1661 );
1662 })();
1663 };
1664 const gitItems = (
1638 <>1665 <>
1639 {!isRepo ? (1666 {gitActionMenuItems.map((item) => {
1640 <Button1667 const disabledReason = getMenuActionDisabledReason({
1641 variant="outline"1668 item,
1642 size="xs"1669 gitStatus: gitStatusForActions,
1643 disabled={initAction.isPending}1670 isBusy: isGitActionRunning,
1671 hasPrimaryRemote,
1672 });
1673 if (item.disabled && disabledReason && presentation === "menu") {
1674 return (
1675 <div key={`${item.id}-${item.label}`}>
1676 <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled>
1677 <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
1678 <MenuItemLabel>{item.label}</MenuItemLabel>
1679 </MenuItem>
1680 <p className="max-w-64 px-2 pb-2 text-xs text-muted-foreground">{disabledReason}</p>
1681 </div>
1682 );
1683 }
1684 if (item.disabled && disabledReason) {
1685 return (
1686 <Popover key={`${item.id}-${item.label}`}>
1687 <PopoverTrigger
1688 openOnHover
1689 nativeButton={false}
1690 render={<span className="block w-max cursor-not-allowed" />}
1691 >
1692 <MenuItem
1693 density={presentation === "menu" ? "touch" : "default"}
1694 className="w-full"
1695 disabled
1696 >
1697 <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
1698 <MenuItemLabel>{item.label}</MenuItemLabel>
1699 </MenuItem>
1700 </PopoverTrigger>
1701 <PopoverPopup tooltipStyle side="left" align="center">
1702 {disabledReason}
1703 </PopoverPopup>
1704 </Popover>
1705 );
1706 }
1707
1708 return (
1709 <MenuItem
1710 density={presentation === "menu" ? "touch" : "default"}
1711 key={`${item.id}-${item.label}`}
1712 disabled={item.disabled}
1713 onClick={() => {
1714 openDialogForMenuItem(item);
1715 }}
1716 >
1717 <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
1718 <MenuItemLabel>{item.label}</MenuItemLabel>
1719 </MenuItem>
1720 );
1721 })}
1722 {canPublishRepository ? (
1723 <MenuItem
1724 density={presentation === "menu" ? "touch" : "default"}
1725 disabled={isGitActionRunning}
1644 onClick={() => {1726 onClick={() => {
1645 void (async () => {1727 setIsPublishDialogOpen(true);
1646 const result = await initAction.run();
1647 if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
1648 return;
1649 }
1650 const error = squashAtomCommandFailure(result);
1651 toastManager.add(
1652 stackedThreadToast({
1653 type: "error",
1654 title: "Git initialization failed",
1655 description: error instanceof Error ? error.message : "An error occurred.",
1656 ...(threadToastData !== undefined ? { data: threadToastData } : {}),
1657 }),
1658 );
1659 })();
1660 }}1728 }}
1661 >1729 >
1730 <CloudUploadIcon />
1731 <MenuItemLabel>Publish repository...</MenuItemLabel>
1732 </MenuItem>
1733 ) : null}
1734 {gitStatusForActions?.refName === null && (
1735 <p className="px-2 py-1.5 text-xs text-warning">
1736 Detached HEAD: create and check out a branch to enable push and pull request actions.
1737 </p>
1738 )}
1739 {gitStatusForActions &&
1740 gitStatusForActions.refName !== null &&
1741 !gitStatusForActions.hasWorkingTreeChanges &&
1742 gitStatusForActions.behindCount > 0 &&
1743 gitStatusForActions.aheadCount === 0 && (
1744 <p className="px-2 py-1.5 text-xs text-warning">Behind upstream. Pull/rebase first.</p>
1745 )}
1746 {gitStatusError && <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError}</p>}
1747 </>
1748 );
1749
1750 if (!gitCwd) return null;
1751
1752 return (
1753 <>
1754 {presentation === "menu" ? (
1755 !isRepo ? (
1756 <MenuItem
1757 density={presentation === "menu" ? "touch" : "default"}
1758
1759 disabled={initAction.isPending}
1760 onClick={initializeGit}
1761 >
1762 <GitBranchPlusIcon className="size-4" />
1763 <MenuItemLabel>
1764 {initAction.isPending ? "Initializing..." : "Initialize Git"}
1765 </MenuItemLabel>
1766 </MenuItem>
1767 ) : (
1768 <>
1769 <MenuItem
1770 density={presentation === "menu" ? "touch" : "default"}
1771
1772 disabled={isGitActionRunning || quickAction.disabled || !!quickActionDisabledReason}
1773 onClick={runQuickAction}
1774 >
1775 <GitQuickActionIcon
1776 className="size-4"
1777 quickAction={quickAction}
1778 SourceControlIcon={SourceControlIcon}
1779 />
1780 <MenuItemLabel>{quickAction.label}</MenuItemLabel>
1781 </MenuItem>
1782 {quickActionDisabledReason && (
1783 <p className="max-w-64 px-2 py-1.5 text-xs text-warning">
1784 {quickActionDisabledReason}
1785 </p>
1786 )}
1787 <MenuSub
1788 onOpenChange={(open) => {
1789 if (open) requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd);
1790 }}
1791 >
1792 <MenuSubTrigger density="touch" disabled={isGitActionRunning}>
1793 <SourceControlIcon className="size-4" />
1794 <MenuItemLabel>Git actions</MenuItemLabel>
1795 </MenuSubTrigger>
1796 <MenuSubPopup className="min-w-32 max-w-[calc(100vw-2rem)]">{gitItems}</MenuSubPopup>
1797 </MenuSub>
1798 </>
1799 )
1800 ) : !isRepo ? (
1801 <Button variant="outline" size="xs" disabled={initAction.isPending} onClick={initializeGit}>
1662 <GitBranchPlusIcon className="size-3.5" aria-hidden />1802 <GitBranchPlusIcon className="size-3.5" aria-hidden />
1663 <span className="ml-0.5">1803 <span className="ml-0.5">
1664 {initAction.isPending ? "Initializing..." : "Initialize Git"}1804 {initAction.isPending ? "Initializing..." : "Initialize Git"}
...@@ -1720,78 +1860,7 @@ export default function GitActionsControl({...@@ -1720,78 +1860,7 @@ export default function GitActionsControl({
1720 <ChevronDownIcon aria-hidden="true" className="size-4" />1860 <ChevronDownIcon aria-hidden="true" className="size-4" />
1721 </MenuTrigger>1861 </MenuTrigger>
1722 <MenuPopup align="end" className="w-full">1862 <MenuPopup align="end" className="w-full">
1723 {gitActionMenuItems.map((item) => {1863 {gitItems}
1724 const disabledReason = getMenuActionDisabledReason({
1725 item,
1726 gitStatus: gitStatusForActions,
1727 isBusy: isGitActionRunning,
1728 hasPrimaryRemote,
1729 });
1730 if (item.disabled && disabledReason) {
1731 return (
1732 <Popover key={`${item.id}-${item.label}`}>
1733 <PopoverTrigger
1734 openOnHover
1735 nativeButton={false}
1736 render={<span className="block w-max cursor-not-allowed" />}
1737 >
1738 <MenuItem className="w-full" disabled>
1739 <GitActionItemIcon
1740 icon={item.icon}
1741 SourceControlIcon={SourceControlIcon}
1742 />
1743 {item.label}
1744 </MenuItem>
1745 </PopoverTrigger>
1746 <PopoverPopup tooltipStyle side="left" align="center">
1747 {disabledReason}
1748 </PopoverPopup>
1749 </Popover>
1750 );
1751 }
1752
1753 return (
1754 <MenuItem
1755 key={`${item.id}-${item.label}`}
1756 disabled={item.disabled}
1757 onClick={() => {
1758 openDialogForMenuItem(item);
1759 }}
1760 >
1761 <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
1762 {item.label}
1763 </MenuItem>
1764 );
1765 })}
1766 {canPublishRepository ? (
1767 <MenuItem
1768 disabled={isGitActionRunning}
1769 onClick={() => {
1770 setIsPublishDialogOpen(true);
1771 }}
1772 >
1773 <CloudUploadIcon />
1774 Publish repository...
1775 </MenuItem>
1776 ) : null}
1777 {gitStatusForActions?.refName === null && (
1778 <p className="px-2 py-1.5 text-xs text-warning">
1779 Detached HEAD: create and check out a branch to enable push and pull request
1780 actions.
1781 </p>
1782 )}
1783 {gitStatusForActions &&
1784 gitStatusForActions.refName !== null &&
1785 !gitStatusForActions.hasWorkingTreeChanges &&
1786 gitStatusForActions.behindCount > 0 &&
1787 gitStatusForActions.aheadCount === 0 && (
1788 <p className="px-2 py-1.5 text-xs text-warning">
1789 Behind upstream. Pull/rebase first.
1790 </p>
1791 )}
1792 {gitStatusError && (
1793 <p className="px-2 py-1.5 text-xs text-destructive">{gitStatusError}</p>
1794 )}
1795 </MenuPopup>1864 </MenuPopup>
1796 </Menu>1865 </Menu>
1797 </Group>1866 </Group>
apps/web/src/components/ProjectScriptsControl.tsx+127-55
...@@ -28,10 +28,14 @@ import {...@@ -28,10 +28,14 @@ import {
28 MenuGroup,28 MenuGroup,
29 MenuGroupLabel,29 MenuGroupLabel,
30 MenuItem,30 MenuItem,
31 MenuItemLabel,
31 MenuPopup,32 MenuPopup,
32 MenuSeparator,33 MenuSeparator,
33 MenuShortcut,34 MenuShortcut,
34 MenuTrigger,35 MenuTrigger,
36 MenuSub,
37 MenuSubTrigger,
38 MenuSubPopup,
35} from "./ui/menu";39} from "./ui/menu";
36import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";40import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
3741
...@@ -40,6 +44,8 @@ export type { NewProjectScriptInput, ProjectScriptActionResult };...@@ -40,6 +44,8 @@ export type { NewProjectScriptInput, ProjectScriptActionResult };
40const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];44const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
4145
42interface ProjectScriptsControlProps {46interface ProjectScriptsControlProps {
47 presentation?: "toolbar" | "menu";
48 onRequestMenuClose?: () => void;
43 scripts: ReadonlyArray<ProjectScript>;49 scripts: ReadonlyArray<ProjectScript>;
44 /** Scripts declared in the project's checked-in t3.json, offered for import. */50 /** Scripts declared in the project's checked-in t3.json, offered for import. */
45 fileScripts?: ReadonlyArray<T3ProjectFileScript>;51 fileScripts?: ReadonlyArray<T3ProjectFileScript>;
...@@ -55,6 +61,8 @@ interface ProjectScriptsControlProps {...@@ -55,6 +61,8 @@ interface ProjectScriptsControlProps {
55}61}
5662
57export default function ProjectScriptsControl({63export default function ProjectScriptsControl({
64 presentation = "toolbar",
65 onRequestMenuClose,
58 scripts,66 scripts,
59 fileScripts = NO_FILE_SCRIPTS,67 fileScripts = NO_FILE_SCRIPTS,
60 keybindings,68 keybindings,
...@@ -65,9 +73,13 @@ export default function ProjectScriptsControl({...@@ -65,9 +73,13 @@ export default function ProjectScriptsControl({
65 onDeleteScript,73 onDeleteScript,
66}: ProjectScriptsControlProps) {74}: ProjectScriptsControlProps) {
67 const [actionsMenuOpen, setActionsMenuOpen] = useState({75 const [actionsMenuOpen, setActionsMenuOpen] = useState({
76 presentation,
68 scripts: false,77 scripts: false,
69 imports: false,78 imports: false,
70 });79 });
80 if (actionsMenuOpen.presentation !== presentation) {
81 setActionsMenuOpen({ presentation, scripts: false, imports: false });
82 }
71 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);83 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
7284
73 const primaryScript = useMemo(() => {85 const primaryScript = useMemo(() => {
...@@ -97,7 +109,8 @@ export default function ProjectScriptsControl({...@@ -97,7 +109,8 @@ export default function ProjectScriptsControl({
97 };109 };
98110
99 const openEditDialog = (script: ProjectScript) => {111 const openEditDialog = (script: ProjectScript) => {
100 setActionsMenuOpen({ scripts: false, imports: false });112 onRequestMenuClose?.();
113 setActionsMenuOpen({ presentation, scripts: false, imports: false });
101 setEditorRequest(editorRequestForScript(script, keybindings));114 setEditorRequest(editorRequestForScript(script, keybindings));
102 };115 };
103116
...@@ -138,12 +151,13 @@ export default function ProjectScriptsControl({...@@ -138,12 +151,13 @@ export default function ProjectScriptsControl({
138 <MenuGroupLabel>From t3.json</MenuGroupLabel>151 <MenuGroupLabel>From t3.json</MenuGroupLabel>
139 {importableScripts.map((fileScript) => (152 {importableScripts.map((fileScript) => (
140 <MenuItem153 <MenuItem
154 density={presentation === "menu" ? "touch" : "default"}
141 key={`${fileScript.name} ${fileScript.command}`}155 key={`${fileScript.name} ${fileScript.command}`}
142 className={dropdownItemClassName}156 className={dropdownItemClassName}
143 onClick={() => void importFileScript(fileScript)}157 onClick={() => void importFileScript(fileScript)}
144 >158 >
145 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4" />159 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4" />
146 <span className="truncate">{fileScript.name}</span>160 <MenuItemLabel className="truncate">{fileScript.name}</MenuItemLabel>
147 <MenuShortcut className="ms-auto">161 <MenuShortcut className="ms-auto">
148 <DownloadIcon className="size-3.5" aria-label="Import" />162 <DownloadIcon className="size-3.5" aria-label="Import" />
149 </MenuShortcut>163 </MenuShortcut>
...@@ -153,9 +167,112 @@ export default function ProjectScriptsControl({...@@ -153,9 +167,112 @@ export default function ProjectScriptsControl({
153 </>167 </>
154 );168 );
155169
170 const scriptItems = (
171 <>
172 {scripts.map((script) => {
173 const shortcutLabel = shortcutLabelForCommand(
174 keybindings,
175 commandForProjectScript(script.id),
176 );
177 return (
178 <MenuItem
179 density={presentation === "menu" ? "touch" : "default"}
180 key={script.id}
181 className={`group ${dropdownItemClassName}`}
182 onClick={() => onRunScript(script)}
183 >
184 <ScriptIcon icon={script.icon} className="size-4" />
185 <MenuItemLabel className="truncate">
186 {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name}
187 </MenuItemLabel>
188 <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end">
189 {shortcutLabel && (
190 <MenuShortcut
191 className={
192 presentation === "menu"
193 ? "ms-0 mr-7"
194 : "ms-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0"
195 }
196 >
197 {shortcutLabel}
198 </MenuShortcut>
199 )}
200 <Button
201 type="button"
202 variant="ghost"
203 size="icon-xs"
204 className={`absolute right-0 top-1/2 size-6 -translate-y-1/2 ${presentation === "menu" ? "" : "opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-visible:opacity-100 group-focus-visible:pointer-events-auto"}`}
205 aria-label={`Edit ${script.name}`}
206 onPointerDown={(event) => {
207 event.preventDefault();
208 event.stopPropagation();
209 }}
210 onClick={(event) => {
211 event.preventDefault();
212 event.stopPropagation();
213 openEditDialog(script);
214 }}
215 >
216 <SettingsIcon className="size-3.5" />
217 </Button>
218 </span>
219 </MenuItem>
220 );
221 })}
222 {importMenuItems}
223 <MenuItem
224 density={presentation === "menu" ? "touch" : "default"}
225 className={dropdownItemClassName}
226 onClick={openAddDialog}
227 >
228 <PlusIcon className="size-4" />
229 <MenuItemLabel>Add action</MenuItemLabel>
230 </MenuItem>
231 </>
232 );
233
156 return (234 return (
157 <>235 <>
158 {primaryScript ? (236 {presentation === "menu" ? (
237 <>
238 {primaryScript && (
239 <MenuItem
240 density={presentation === "menu" ? "touch" : "default"}
241 onClick={() => onRunScript(primaryScript)}
242 >
243 <ScriptIcon icon={primaryScript.icon} className="size-4" />
244 <MenuItemLabel className="truncate">Run {primaryScript.name}</MenuItemLabel>
245 <MenuShortcut>
246 {shortcutLabelForCommand(keybindings, commandForProjectScript(primaryScript.id))}
247 </MenuShortcut>
248 </MenuItem>
249 )}
250 {primaryScript || importableScripts.length > 0 ? (
251 <MenuSub
252 open={actionsMenuOpen.scripts}
253 onOpenChange={(open) =>
254 setActionsMenuOpen({ presentation, scripts: open, imports: false })
255 }
256 >
257 <MenuSubTrigger density="touch">
258 <ScriptIcon icon="play" className="size-4" />
259 <MenuItemLabel>Project actions</MenuItemLabel>
260 </MenuSubTrigger>
261 <MenuSubPopup className="min-w-32 max-w-[calc(100vw-2rem)]">
262 {scriptItems}
263 </MenuSubPopup>
264 </MenuSub>
265 ) : (
266 <MenuItem
267 density={presentation === "menu" ? "touch" : "default"}
268 onClick={openAddDialog}
269 >
270 <PlusIcon className="size-4" />
271 <MenuItemLabel>Add project action…</MenuItemLabel>
272 </MenuItem>
273 )}
274 </>
275 ) : primaryScript ? (
159 <Group aria-label="Project scripts">276 <Group aria-label="Project scripts">
160 <Tooltip>277 <Tooltip>
161 <TooltipTrigger278 <TooltipTrigger
...@@ -183,70 +300,25 @@ export default function ProjectScriptsControl({...@@ -183,70 +300,25 @@ export default function ProjectScriptsControl({
183 <Menu300 <Menu
184 highlightItemOnHover={false}301 highlightItemOnHover={false}
185 open={actionsMenuOpen.scripts}302 open={actionsMenuOpen.scripts}
186 onOpenChange={(open) => setActionsMenuOpen({ scripts: open, imports: false })}303 onOpenChange={(open) =>
304 setActionsMenuOpen({ presentation, scripts: open, imports: false })
305 }
187 >306 >
188 <MenuTrigger307 <MenuTrigger
189 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}308 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
190 >309 >
191 <ChevronDownIcon className="size-4" />310 <ChevronDownIcon className="size-4" />
192 </MenuTrigger>311 </MenuTrigger>
193 <MenuPopup align="end">312 <MenuPopup align="end">{scriptItems}</MenuPopup>
194 {scripts.map((script) => {
195 const shortcutLabel = shortcutLabelForCommand(
196 keybindings,
197 commandForProjectScript(script.id),
198 );
199 return (
200 <MenuItem
201 key={script.id}
202 className={`group ${dropdownItemClassName}`}
203 onClick={() => onRunScript(script)}
204 >
205 <ScriptIcon icon={script.icon} className="size-4" />
206 <span className="truncate">
207 {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name}
208 </span>
209 <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end">
210 {shortcutLabel && (
211 <MenuShortcut className="ms-0 transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0">
212 {shortcutLabel}
213 </MenuShortcut>
214 )}
215 <Button
216 type="button"
217 variant="ghost"
218 size="icon-xs"
219 className="absolute right-0 top-1/2 size-6 -translate-y-1/2 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto group-focus-visible:opacity-100 group-focus-visible:pointer-events-auto"
220 aria-label={`Edit ${script.name}`}
221 onPointerDown={(event) => {
222 event.preventDefault();
223 event.stopPropagation();
224 }}
225 onClick={(event) => {
226 event.preventDefault();
227 event.stopPropagation();
228 openEditDialog(script);
229 }}
230 >
231 <SettingsIcon className="size-3.5" />
232 </Button>
233 </span>
234 </MenuItem>
235 );
236 })}
237 {importMenuItems}
238 <MenuItem className={dropdownItemClassName} onClick={openAddDialog}>
239 <PlusIcon className="size-4" />
240 Add action
241 </MenuItem>
242 </MenuPopup>
243 </Menu>313 </Menu>
244 </Group>314 </Group>
245 ) : importableScripts.length > 0 ? (315 ) : importableScripts.length > 0 ? (
246 <Menu316 <Menu
247 highlightItemOnHover={false}317 highlightItemOnHover={false}
248 open={actionsMenuOpen.imports}318 open={actionsMenuOpen.imports}
249 onOpenChange={(open) => setActionsMenuOpen({ scripts: false, imports: open })}319 onOpenChange={(open) =>
320 setActionsMenuOpen({ presentation, scripts: false, imports: open })
321 }
250 >322 >
251 <MenuTrigger render={<Button size="xs" variant="outline" aria-label="Project actions" />}>323 <MenuTrigger render={<Button size="xs" variant="outline" aria-label="Project actions" />}>
252 <PlusIcon className="size-3.5" />324 <PlusIcon className="size-3.5" />
apps/web/src/components/WorkspaceBreadcrumb.tsx+26-3
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { ReactNode } from "react";1import type { ComponentProps, ReactNode } from "react";
22
3import { cn } from "../lib/utils";3import { cn } from "../lib/utils";
44
...@@ -26,6 +26,23 @@ interface WorkspaceBreadcrumbItemProps {...@@ -26,6 +26,23 @@ interface WorkspaceBreadcrumbItemProps {
26 readonly current?: boolean;26 readonly current?: boolean;
27}27}
2828
29export function WorkspaceBreadcrumbText({ children, className, ...props }: ComponentProps<"span">) {
30 return (
31 <span
32 data-slot="workspace-breadcrumb-text"
33 // Center the capital letters with adjacent icons, not the font's leading.
34 // Padding preserves accents and descenders when a label is truncated.
35 className={cn(
36 "block min-w-0 truncate [text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both_cap_alphabetic]:py-[0.5em]",
37 className,
38 )}
39 {...props}
40 >
41 {children}
42 </span>
43 );
44}
45
29export function WorkspaceBreadcrumbItem({46export function WorkspaceBreadcrumbItem({
30 children,47 children,
31 className,48 className,
...@@ -45,10 +62,16 @@ export function WorkspaceBreadcrumbItem({...@@ -45,10 +62,16 @@ export function WorkspaceBreadcrumbItem({
45 );62 );
46}63}
4764
48export function WorkspaceBreadcrumbSeparator({ className }: { readonly className?: string }) {65export function WorkspaceBreadcrumbSeparator({
66 className,
67 children = "/",
68}: {
69 readonly className?: string;
70 readonly children?: ReactNode;
71}) {
49 return (72 return (
50 <li aria-hidden="true" className={cn("flex shrink-0 items-center text-icon-muted", className)}>73 <li aria-hidden="true" className={cn("flex shrink-0 items-center text-icon-muted", className)}>
51 /74 {children}
52 </li>75 </li>
53 );76 );
54}77}
apps/web/src/components/chat/ChatHeader.tsx+125-39
...@@ -11,7 +11,7 @@ import {...@@ -11,7 +11,7 @@ import {
11 isAtomCommandInterrupted,11 isAtomCommandInterrupted,
12 squashAtomCommandFailure,12 squashAtomCommandFailure,
13} from "@t3tools/client-runtime/state/runtime";13} from "@t3tools/client-runtime/state/runtime";
14import { ChevronDownIcon } from "lucide-react";14import { ChevronDownIcon, EllipsisIcon } from "lucide-react";
15import {15import {
16 memo,16 memo,
17 useCallback,17 useCallback,
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22 type KeyboardEvent as ReactKeyboardEvent,22 type KeyboardEvent as ReactKeyboardEvent,
23 type MouseEvent as ReactMouseEvent,23 type MouseEvent as ReactMouseEvent,
24} from "react";24} from "react";
25import { createPortal } from "react-dom";
25import GitActionsControl from "../GitActionsControl";26import GitActionsControl from "../GitActionsControl";
26import { isTrailingDoubleClick } from "../Sidebar.logic";27import { isTrailingDoubleClick } from "../Sidebar.logic";
27import { type DraftId } from "~/composerDraftStore";28import { type DraftId } from "~/composerDraftStore";
...@@ -45,8 +46,12 @@ import {...@@ -45,8 +46,12 @@ import {
45 WorkspaceBreadcrumb,46 WorkspaceBreadcrumb,
46 WorkspaceBreadcrumbItem,47 WorkspaceBreadcrumbItem,
47 WorkspaceBreadcrumbSeparator,48 WorkspaceBreadcrumbSeparator,
49 WorkspaceBreadcrumbText,
48} from "../WorkspaceBreadcrumb";50} from "../WorkspaceBreadcrumb";
49import { cn } from "~/lib/utils";51import { cn } from "~/lib/utils";
52import { useIsMobile } from "~/hooks/useMediaQuery";
53import { Button } from "../ui/button";
54import { Menu, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
5055
51interface ChatHeaderProps {56interface ChatHeaderProps {
52 activeThreadEnvironmentId: EnvironmentId;57 activeThreadEnvironmentId: EnvironmentId;
...@@ -155,6 +160,40 @@ export const ChatHeader = memo(function ChatHeader({...@@ -155,6 +160,40 @@ export const ChatHeader = memo(function ChatHeader({
155 breakpoint: { value: HEADER_ACTIONS_EXPANDED_BREAKPOINT_REM, unit: "rem" },160 breakpoint: { value: HEADER_ACTIONS_EXPANDED_BREAKPOINT_REM, unit: "rem" },
156 });161 });
157 }, [panelAnimationDurationMs, panelAnimationsActive]);162 }, [panelAnimationDurationMs, panelAnimationsActive]);
163 const isMobile = useIsMobile();
164 // Side panels can leave a desktop header narrower than a phone.
165 const [isNarrowHeader, setIsNarrowHeader] = useState(false);
166 useEffect(() => {
167 const container = headerActionsRef.current?.parentElement;
168 if (!container) return;
169 const update = () => setIsNarrowHeader(container.clientWidth < 512);
170 update();
171 const observer = new ResizeObserver(update);
172 observer.observe(container);
173 return () => observer.disconnect();
174 }, []);
175 const actionsCollapsed = isMobile || isNarrowHeader;
176 const [actionsOpen, setActionsOpen] = useState(false);
177 const [actionsContainer] = useState(() => {
178 const container = document.createElement("div");
179 container.className = "contents";
180 return container;
181 });
182 // Reparent the DOM host, not the React controls: rotating a phone or resizing
183 // a window must not discard an unsaved script or Git dialog.
184 const mountInlineActions = useCallback(
185 (node: HTMLDivElement | null) => {
186 if (node && !actionsCollapsed) node.appendChild(actionsContainer);
187 },
188 [actionsContainer, actionsCollapsed],
189 );
190 const mountMenuActions = useCallback(
191 (node: HTMLDivElement | null) => {
192 if (node && actionsCollapsed) node.appendChild(actionsContainer);
193 },
194 [actionsContainer, actionsCollapsed],
195 );
196 if (!actionsCollapsed && actionsOpen) setActionsOpen(false);
158 const primaryEnvironmentId = usePrimaryEnvironmentId();197 const primaryEnvironmentId = usePrimaryEnvironmentId();
159 const activeProjectName = activeProject?.title;198 const activeProjectName = activeProject?.title;
160 const activeProjectCwd = activeProject?.workspaceRoot ?? null;199 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
...@@ -312,6 +351,50 @@ export const ChatHeader = memo(function ChatHeader({...@@ -312,6 +351,50 @@ export const ChatHeader = memo(function ChatHeader({
312 },351 },
313 [commitRename],352 [commitRename],
314 );353 );
354 const headerActions = (
355 <>
356 {activeProjectScripts && (
357 <>
358 <ProjectScriptsControl
359 onRequestMenuClose={() => setActionsOpen(false)}
360 presentation={actionsCollapsed ? "menu" : "toolbar"}
361 scripts={activeProjectScripts}
362 fileScripts={fileScripts}
363 keybindings={keybindings}
364 preferredScriptId={preferredScriptId}
365 onRunScript={onRunProjectScript}
366 onAddScript={onAddProjectScript}
367 onUpdateScript={onUpdateProjectScript}
368 onDeleteScript={onDeleteProjectScript}
369 />
370 </>
371 )}
372 {showOpenInPicker && (
373 <>
374 {actionsCollapsed && activeProjectScripts && <MenuSeparator />}
375 <OpenInPicker
376 presentation={actionsCollapsed ? "menu" : "toolbar"}
377 environmentId={activeThreadEnvironmentId}
378 keybindings={keybindings}
379 availableEditors={availableEditors}
380 openInCwd={openInCwd}
381 />
382 </>
383 )}
384 {activeProjectName && gitCwd && (
385 <>
386 {actionsCollapsed && (activeProjectScripts || showOpenInPicker) && <MenuSeparator />}
387 <GitActionsControl
388 presentation={actionsCollapsed ? "menu" : "toolbar"}
389 gitCwd={gitCwd}
390 activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)}
391 onOpenPullRequest={onOpenPullRequest}
392 {...(draftId ? { draftId } : {})}
393 />
394 </>
395 )}
396 </>
397 );
315 return (398 return (
316 <div399 <div
317 className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"400 className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"
...@@ -339,12 +422,16 @@ export const ChatHeader = memo(function ChatHeader({...@@ -339,12 +422,16 @@ export const ChatHeader = memo(function ChatHeader({
339 }422 }
340 >423 >
341 <ProjectFavicon project={activeProject} className="size-3.5" />424 <ProjectFavicon project={activeProject} className="size-3.5" />
342 <span className="max-w-40 truncate">{activeProjectName}</span>425 <WorkspaceBreadcrumbText className="max-w-40">
426 {activeProjectName}
427 </WorkspaceBreadcrumbText>
343 </TooltipTrigger>428 </TooltipTrigger>
344 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>429 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
345 </Tooltip>430 </Tooltip>
346 </WorkspaceBreadcrumbItem>431 </WorkspaceBreadcrumbItem>
347 <WorkspaceBreadcrumbSeparator />432 <WorkspaceBreadcrumbSeparator>
433 <WorkspaceBreadcrumbText>/</WorkspaceBreadcrumbText>
434 </WorkspaceBreadcrumbSeparator>
348 </>435 </>
349 ) : null}436 ) : null}
350 <WorkspaceBreadcrumbItem current className="min-w-10 flex-1">437 <WorkspaceBreadcrumbItem current className="min-w-10 flex-1">
...@@ -377,7 +464,9 @@ export const ChatHeader = memo(function ChatHeader({...@@ -377,7 +464,9 @@ export const ChatHeader = memo(function ChatHeader({
377 />464 />
378 }465 }
379 >466 >
380 <h2 className="min-w-0 truncate">{activeThreadTitle}</h2>467 <h2 className="min-w-0">
468 <WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText>
469 </h2>
381 <ChevronDownIcon470 <ChevronDownIcon
382 aria-hidden471 aria-hidden
383 data-thread-title-chevron472 data-thread-title-chevron
...@@ -389,12 +478,10 @@ export const ChatHeader = memo(function ChatHeader({...@@ -389,12 +478,10 @@ export const ChatHeader = memo(function ChatHeader({
389 ) : (478 ) : (
390 <Tooltip>479 <Tooltip>
391 <TooltipTrigger480 <TooltipTrigger
392 render={481 render={<h2 aria-label={activeThreadTitle} className="min-w-0 flex-1" />}
393 <h2 aria-label={activeThreadTitle} className="min-w-0 flex-1 truncate">482 >
394 {activeThreadTitle}483 <WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText>
395 </h2>484 </TooltipTrigger>
396 }
397 />
398 <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>485 <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
399 </Tooltip>486 </Tooltip>
400 )}487 )}
...@@ -405,38 +492,37 @@ export const ChatHeader = memo(function ChatHeader({...@@ -405,38 +492,37 @@ export const ChatHeader = memo(function ChatHeader({
405 data-chat-header-actions492 data-chat-header-actions
406 className={cn(493 className={cn(
407 "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3",494 "flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3",
408 rightPanelOpen ? "pr-0" : "pr-16",495 // Reserve two panel toggles plus their 4px gaps and 1px edge inset.
496 // The page header adds 8px more right padding at sm.
497 rightPanelOpen ? "pr-0" : "pr-[calc(--spacing(18)+1px)] sm:pr-[calc(--spacing(14)+1px)]",
409 "[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:motion-safe:ease-out",498 "[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:motion-safe:ease-out",
410 )}499 )}
411 >500 >
412 {activeProjectScripts && (501 <Menu open={actionsCollapsed && actionsOpen} onOpenChange={setActionsOpen}>
413 <ProjectScriptsControl502 <MenuTrigger
414 scripts={activeProjectScripts}503 className={
415 fileScripts={fileScripts}504 actionsCollapsed &&
416 keybindings={keybindings}505 (activeProjectScripts || showOpenInPicker || (activeProjectName && gitCwd))
417 preferredScriptId={preferredScriptId}506 ? undefined
418 onRunScript={onRunProjectScript}507 : "hidden"
419 onAddScript={onAddProjectScript}508 }
420 onUpdateScript={onUpdateProjectScript}509 render={<Button size="icon-sm" variant="ghost" aria-label="More header actions" />}
421 onDeleteScript={onDeleteProjectScript}510 >
422 />511 <EllipsisIcon className="size-4" />
423 )}512 </MenuTrigger>
424 {showOpenInPicker && (513 <div ref={mountInlineActions} className="contents" />
425 <OpenInPicker514 <MenuPopup
426 environmentId={activeThreadEnvironmentId}515 data-chat-header-actions
427 keybindings={keybindings}516 keepMounted
428 availableEditors={availableEditors}517 aria-label="Header actions"
429 openInCwd={openInCwd}518 align="end"
430 />519 className="min-w-56 max-w-[calc(100vw-2rem)]"
431 )}520 finalFocus={actionsCollapsed ? undefined : false}
432 {activeProjectName && (521 >
433 <GitActionsControl522 <div ref={mountMenuActions} className="contents" />
434 gitCwd={gitCwd}523 {createPortal(headerActions, actionsContainer)}
435 activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)}524 </MenuPopup>
436 onOpenPullRequest={onOpenPullRequest}525 </Menu>
437 {...(draftId ? { draftId } : {})}
438 />
439 )}
440 </div>526 </div>
441 </div>527 </div>
442 );528 );
apps/web/src/components/chat/OpenInPicker.tsx+78-23
...@@ -15,10 +15,20 @@ import {...@@ -15,10 +15,20 @@ import {
15 useRemoteOpenState,15 useRemoteOpenState,
16} from "../../remoteOpen";16} from "../../remoteOpen";
17import { useEnvironment } from "../../state/environments";17import { useEnvironment } from "../../state/environments";
18import { ChevronDownIcon, FolderClosedIcon } from "lucide-react";18import { ChevronDownIcon, FolderClosedIcon, SquareArrowOutUpRightIcon } from "lucide-react";
19import { Button } from "../ui/button";19import { Button } from "../ui/button";
20import { Group, GroupSeparator } from "../ui/group";20import { Group, GroupSeparator } from "../ui/group";
21import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu";21import {
22 Menu,
23 MenuItem,
24 MenuItemLabel,
25 MenuPopup,
26 MenuShortcut,
27 MenuSub,
28 MenuSubTrigger,
29 MenuSubPopup,
30 MenuTrigger,
31} from "../ui/menu";
22import {32import {
23 AntigravityIcon,33 AntigravityIcon,
24 CursorIcon,34 CursorIcon,
...@@ -187,6 +197,7 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -187,6 +197,7 @@ export const OpenInPicker = memo(function OpenInPicker({
187 keybindings,197 keybindings,
188 availableEditors,198 availableEditors,
189 openInCwd,199 openInCwd,
200 presentation = "toolbar",
190 compact = false,201 compact = false,
191 enableShortcut = true,202 enableShortcut = true,
192}: {203}: {
...@@ -194,6 +205,7 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -194,6 +205,7 @@ export const OpenInPicker = memo(function OpenInPicker({
194 keybindings: ResolvedKeybindingsConfig;205 keybindings: ResolvedKeybindingsConfig;
195 availableEditors: ReadonlyArray<EditorId>;206 availableEditors: ReadonlyArray<EditorId>;
196 openInCwd: string | null;207 openInCwd: string | null;
208 presentation?: "toolbar" | "menu";
197 compact?: boolean;209 compact?: boolean;
198 enableShortcut?: boolean;210 enableShortcut?: boolean;
199}) {211}) {
...@@ -274,6 +286,69 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -274,6 +286,69 @@ export const OpenInPicker = memo(function OpenInPicker({
274 return () => window.removeEventListener("keydown", handler);286 return () => window.removeEventListener("keydown", handler);
275 }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]);287 }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]);
276288
289 const editorItems = (
290 <>
291 {remote.mode === "remote-unavailable" ? (
292 <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled>
293 No SSH route to {environmentLabel}
294 </MenuItem>
295 ) : (
296 <>
297 {options.length === 0 && (
298 <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled>
299 No installed editors found
300 </MenuItem>
301 )}
302 {options.map(({ label, Icon, value, kind }) => (
303 <MenuItem
304 density={presentation === "menu" ? "touch" : "default"}
305 key={value}
306 onClick={() => openInEditor(value)}
307 >
308 <Icon aria-hidden="true" className={getOpenInIconClass(kind)} />
309 <MenuItemLabel>{label}</MenuItemLabel>
310 {value === preferredEditor && openFavoriteEditorShortcutLabel && (
311 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
312 )}
313 </MenuItem>
314 ))}
315 {remote.mode === "remote-links" && !remoteHintSeen && (
316 <MenuItem density={presentation === "menu" ? "touch" : "default"} disabled>
317 Opens over SSH. Needs your key on {environmentLabel}
318 </MenuItem>
319 )}
320 </>
321 )}
322 </>
323 );
324 if (presentation === "menu") {
325 return (
326 <>
327 {primaryOption && (
328 <MenuItem
329 density={presentation === "menu" ? "touch" : "default"}
330
331 disabled={!openInCwd || remote.mode === "remote-unavailable"}
332 onClick={() => openInEditor(preferredEditor)}
333 >
334 <primaryOption.Icon className={cn("size-4", getOpenInIconClass(primaryOption.kind))} />
335 <MenuItemLabel className="truncate">Open in {primaryOption.label}</MenuItemLabel>
336 {openFavoriteEditorShortcutLabel && (
337 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
338 )}
339 </MenuItem>
340 )}
341 <MenuSub>
342 <MenuSubTrigger density="touch">
343 <SquareArrowOutUpRightIcon className="size-4" />
344 <MenuItemLabel>Open in…</MenuItemLabel>
345 </MenuSubTrigger>
346 <MenuSubPopup className="min-w-32 max-w-[calc(100vw-2rem)]">{editorItems}</MenuSubPopup>
347 </MenuSub>
348 </>
349 );
350 }
351
277 return (352 return (
278 <Group aria-label="Open in editor">353 <Group aria-label="Open in editor">
279 <Button354 <Button
...@@ -307,27 +382,7 @@ export const OpenInPicker = memo(function OpenInPicker({...@@ -307,27 +382,7 @@ export const OpenInPicker = memo(function OpenInPicker({
307 >382 >
308 <ChevronDownIcon aria-hidden="true" className="size-4" />383 <ChevronDownIcon aria-hidden="true" className="size-4" />
309 </MenuTrigger>384 </MenuTrigger>
310 <MenuPopup align="end">385 <MenuPopup align="end">{editorItems}</MenuPopup>
311 {remote.mode === "remote-unavailable" ? (
312 <MenuItem disabled>No SSH route to {environmentLabel}</MenuItem>
313 ) : (
314 <>
315 {options.length === 0 && <MenuItem disabled>No installed editors found</MenuItem>}
316 {options.map(({ label, Icon, value, kind }) => (
317 <MenuItem key={value} onClick={() => openInEditor(value)}>
318 <Icon aria-hidden="true" className={getOpenInIconClass(kind)} />
319 {label}
320 {value === preferredEditor && openFavoriteEditorShortcutLabel && (
321 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
322 )}
323 </MenuItem>
324 ))}
325 {remote.mode === "remote-links" && !remoteHintSeen && (
326 <MenuItem disabled>Opens over SSH. Needs your key on {environmentLabel}</MenuItem>
327 )}
328 </>
329 )}
330 </MenuPopup>
331 </Menu>386 </Menu>
332 </Group>387 </Group>
333 );388 );
apps/web/src/components/ui/menu.tsx+27-1
...@@ -25,6 +25,7 @@ function MenuPopup({...@@ -25,6 +25,7 @@ function MenuPopup({
25 alignOffset,25 alignOffset,
26 side = "bottom",26 side = "bottom",
27 anchor,27 anchor,
28 keepMounted = false,
28 ...props29 ...props
29}: MenuPrimitive.Popup.Props & {30}: MenuPrimitive.Popup.Props & {
30 align?: MenuPrimitive.Positioner.Props["align"];31 align?: MenuPrimitive.Positioner.Props["align"];
...@@ -32,6 +33,7 @@ function MenuPopup({...@@ -32,6 +33,7 @@ function MenuPopup({
32 alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];33 alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
33 side?: MenuPrimitive.Positioner.Props["side"];34 side?: MenuPrimitive.Positioner.Props["side"];
34 anchor?: MenuPrimitive.Positioner.Props["anchor"];35 anchor?: MenuPrimitive.Positioner.Props["anchor"];
36 keepMounted?: boolean;
35}) {37}) {
36 const hasExplicitWidthClass =38 const hasExplicitWidthClass =
37 typeof className === "string" &&39 typeof className === "string" &&
...@@ -41,7 +43,7 @@ function MenuPopup({...@@ -41,7 +43,7 @@ function MenuPopup({
41 });43 });
4244
43 return (45 return (
44 <MenuPrimitive.Portal>46 <MenuPrimitive.Portal keepMounted={keepMounted}>
45 <MenuPrimitive.Positioner47 <MenuPrimitive.Positioner
46 align={align}48 align={align}
47 alignOffset={alignOffset}49 alignOffset={alignOffset}
...@@ -74,10 +76,12 @@ function MenuGroup(props: MenuPrimitive.Group.Props) {...@@ -74,10 +76,12 @@ function MenuGroup(props: MenuPrimitive.Group.Props) {
74function MenuItem({76function MenuItem({
75 className,77 className,
76 inset,78 inset,
79 density = "default",
77 variant = "default",80 variant = "default",
78 ...props81 ...props
79}: MenuPrimitive.Item.Props & {82}: MenuPrimitive.Item.Props & {
80 inset?: boolean;83 inset?: boolean;
84 density?: "default" | "touch";
81 variant?: "default" | "destructive" | "ghost";85 variant?: "default" | "destructive" | "ghost";
82}) {86}) {
83 return (87 return (
...@@ -90,8 +94,10 @@ function MenuItem({...@@ -90,8 +94,10 @@ function MenuItem({
90 size: "compact",94 size: "compact",
91 className: "h-auto min-h-7 w-full sm:text-xs",95 className: "h-auto min-h-7 w-full sm:text-xs",
92 }),96 }),
97 density === "touch" && "min-h-10 sm:min-h-10",
93 className,98 className,
94 )}99 )}
100 data-density={density}
95 data-inset={inset}101 data-inset={inset}
96 data-slot="menu-item"102 data-slot="menu-item"
97 data-variant={variant}103 data-variant={variant}
...@@ -100,6 +106,21 @@ function MenuItem({...@@ -100,6 +106,21 @@ function MenuItem({
100 );106 );
101}107}
102108
109// Trim font leading so visible letters center with the icons in touch rows.
110// Symmetric padding keeps accents and descenders inside truncated labels.
111function MenuItemLabel({ className, ...props }: React.ComponentProps<"span">) {
112 return (
113 <span
114 data-slot="menu-item-label"
115 className={cn(
116 "min-w-0 in-data-[density=touch]:[text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both_cap_alphabetic]:in-data-[density=touch]:py-[0.5em]",
117 className,
118 )}
119 {...props}
120 />
121 );
122}
123
103function MenuCheckboxItem({124function MenuCheckboxItem({
104 className,125 className,
105 children,126 children,
...@@ -247,10 +268,12 @@ function MenuSub(props: MenuPrimitive.SubmenuRoot.Props) {...@@ -247,10 +268,12 @@ function MenuSub(props: MenuPrimitive.SubmenuRoot.Props) {
247function MenuSubTrigger({268function MenuSubTrigger({
248 className,269 className,
249 inset,270 inset,
271 density = "default",
250 children,272 children,
251 ...props273 ...props
252}: MenuPrimitive.SubmenuTrigger.Props & {274}: MenuPrimitive.SubmenuTrigger.Props & {
253 inset?: boolean;275 inset?: boolean;
276 density?: "default" | "touch";
254}) {277}) {
255 return (278 return (
256 <MenuPrimitive.SubmenuTrigger279 <MenuPrimitive.SubmenuTrigger
...@@ -261,8 +284,10 @@ function MenuSubTrigger({...@@ -261,8 +284,10 @@ function MenuSubTrigger({
261 // also a direct svg — on a sub-trigger with no leading icon it is the284 // also a direct svg — on a sub-trigger with no leading icon it is the
262 // only one, and these rules would take away its `ms-auto` alignment.285 // only one, and these rules would take away its `ms-auto` alignment.
263 "[&>svg:not(:last-child)]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:not(:last-child):not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0",286 "[&>svg:not(:last-child)]:-mx-0.5 flex min-h-8 cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:cursor-not-allowed data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&>svg:not(:last-child):not([class*='opacity-'])]:opacity-80 [&_svg]:pointer-events-none [&>svg]:shrink-0",
287 density === "touch" && "min-h-10 sm:min-h-10",
264 className,288 className,
265 )}289 )}
290 data-density={density}
266 data-inset={inset}291 data-inset={inset}
267 data-slot="menu-sub-trigger"292 data-slot="menu-sub-trigger"
268 {...props}293 {...props}
...@@ -309,6 +334,7 @@ export {...@@ -309,6 +334,7 @@ export {
309 MenuGroup,334 MenuGroup,
310 MenuGroup as DropdownMenuGroup,335 MenuGroup as DropdownMenuGroup,
311 MenuItem,336 MenuItem,
337 MenuItemLabel,
312 MenuItem as DropdownMenuItem,338 MenuItem as DropdownMenuItem,
313 MenuCheckboxItem,339 MenuCheckboxItem,
314 MenuCheckboxItem as DropdownMenuCheckboxItem,340 MenuCheckboxItem as DropdownMenuCheckboxItem,
apps/web/src/components/ui/sidebar.tsx+5-2
...@@ -155,7 +155,10 @@ function SidebarProvider({...@@ -155,7 +155,10 @@ function SidebarProvider({
155 <SidebarContext value={contextValue}>155 <SidebarContext value={contextValue}>
156 <div156 <div
157 // Inset layouts opt into bg-sidebar through className.157 // Inset layouts opt into bg-sidebar through className.
158 className={cn("group/sidebar-wrapper flex min-h-svh w-full", className)}158 className={cn(
159 "group/sidebar-wrapper flex min-h-svh w-full max-sm:[--workspace-titlebar-control-size:--spacing(8)]",
160 className,
161 )}
159 data-sidebar-state={state}162 data-sidebar-state={state}
160 data-slot="sidebar-wrapper"163 data-slot="sidebar-wrapper"
161 style={164 style={
...@@ -337,7 +340,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t...@@ -337,7 +340,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
337 variant="ghost"340 variant="ghost"
338 {...props}341 {...props}
339 >342 >
340 {isOpen ? <PanelLeftCloseIcon /> : <PanelLeftIcon />}343 {isOpen ? <PanelLeftCloseIcon className="size-4" /> : <PanelLeftIcon className="size-4" />}
341 <span className="sr-only">Toggle Sidebar</span>344 <span className="sr-only">Toggle Sidebar</span>
342 </Button>345 </Button>
343 );346 );