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 {
7979} from "~/components/ui/dialog";
8080import { Group, GroupSeparator } from "~/components/ui/group";
8181import { 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";
8392import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
8493import { ScrollArea } from "~/components/ui/scroll-area";
8594import { Textarea } from "~/components/ui/textarea";
......@@ -108,6 +117,7 @@ import { useOpenLink } from "~/browser/useOpenLink";
108117import { useOpenPrLink } from "~/lib/openPullRequestLink";
109118
110119interface GitActionsControlProps {
120 presentation?: "toolbar" | "menu";
111121 gitCwd: string | null;
112122 activeThreadRef: ScopedThreadRef | null;
113123 draftId?: DraftId;
......@@ -374,24 +384,25 @@ function GitActionItemIcon({
374384function GitQuickActionIcon({
375385 quickAction,
376386 SourceControlIcon,
387 className = "size-3.5",
377388}: {
378389 quickAction: GitQuickAction;
390 className?: string;
379391 SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];
380392}) {
381 const iconClassName = "size-3.5";
382 if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />;
383 if (quickAction.kind === "open_publish") return <CloudUploadIcon className={iconClassName} />;
384 if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={iconClassName} />;
393 if (quickAction.kind === "open_pr") return <SourceControlIcon className={className} />;
394 if (quickAction.kind === "open_publish") return <CloudUploadIcon className={className} />;
395 if (quickAction.kind === "run_pull") return <CloudDownloadIcon className={className} />;
385396 if (quickAction.kind === "run_action") {
386 if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />;
397 if (quickAction.action === "commit") return <GitCommitIcon className={className} />;
387398 if (quickAction.action === "push" || quickAction.action === "commit_push") {
388 return <CloudUploadIcon className={iconClassName} />;
399 return <CloudUploadIcon className={className} />;
389400 }
390 return <SourceControlIcon className={iconClassName} />;
401 return <SourceControlIcon className={className} />;
391402 }
392 if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />;
393 if (quickAction.label === "Push") return <CloudUploadIcon className={iconClassName} />;
394 return <InfoIcon className={iconClassName} />;
403 if (quickAction.label === "Commit") return <GitCommitIcon className={className} />;
404 if (quickAction.label === "Push") return <CloudUploadIcon className={className} />;
405 return <InfoIcon className={className} />;
395406}
396407
397408interface PublishRepositoryDialogProps {
......@@ -944,6 +955,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
944955}
945956
946957export default function GitActionsControl({
958 presentation = "toolbar",
947959 gitCwd,
948960 activeThreadRef,
949961 draftId,
......@@ -1632,33 +1644,161 @@ export default function GitActionsControl({
16321644
16331645 const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote;
16341646
1635 if (!gitCwd) return null;
1636
1637 return (
1647 const initializeGit = () => {
1648 void (async () => {
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 = (
16381665 <>
1639 {!isRepo ? (
1640 <Button
1641 variant="outline"
1642 size="xs"
1643 disabled={initAction.isPending}
1666 {gitActionMenuItems.map((item) => {
1667 const disabledReason = getMenuActionDisabledReason({
1668 item,
1669 gitStatus: gitStatusForActions,
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}
16441726 onClick={() => {
1645 void (async () => {
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 })();
1727 setIsPublishDialogOpen(true);
16601728 }}
16611729 >
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}>
16621802 <GitBranchPlusIcon className="size-3.5" aria-hidden />
16631803 <span className="ml-0.5">
16641804 {initAction.isPending ? "Initializing..." : "Initialize Git"}
......@@ -1720,78 +1860,7 @@ export default function GitActionsControl({
17201860 <ChevronDownIcon aria-hidden="true" className="size-4" />
17211861 </MenuTrigger>
17221862 <MenuPopup align="end" className="w-full">
1723 {gitActionMenuItems.map((item) => {
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 )}
1863 {gitItems}
17951864 </MenuPopup>
17961865 </Menu>
17971866 </Group>
apps/web/src/components/ProjectScriptsControl.tsx+127-55
......@@ -28,10 +28,14 @@ import {
2828 MenuGroup,
2929 MenuGroupLabel,
3030 MenuItem,
31 MenuItemLabel,
3132 MenuPopup,
3233 MenuSeparator,
3334 MenuShortcut,
3435 MenuTrigger,
36 MenuSub,
37 MenuSubTrigger,
38 MenuSubPopup,
3539} from "./ui/menu";
3640import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
3741
......@@ -40,6 +44,8 @@ export type { NewProjectScriptInput, ProjectScriptActionResult };
4044const NO_FILE_SCRIPTS: ReadonlyArray<T3ProjectFileScript> = [];
4145
4246interface ProjectScriptsControlProps {
47 presentation?: "toolbar" | "menu";
48 onRequestMenuClose?: () => void;
4349 scripts: ReadonlyArray<ProjectScript>;
4450 /** Scripts declared in the project's checked-in t3.json, offered for import. */
4551 fileScripts?: ReadonlyArray<T3ProjectFileScript>;
......@@ -55,6 +61,8 @@ interface ProjectScriptsControlProps {
5561}
5662
5763export default function ProjectScriptsControl({
64 presentation = "toolbar",
65 onRequestMenuClose,
5866 scripts,
5967 fileScripts = NO_FILE_SCRIPTS,
6068 keybindings,
......@@ -65,9 +73,13 @@ export default function ProjectScriptsControl({
6573 onDeleteScript,
6674}: ProjectScriptsControlProps) {
6775 const [actionsMenuOpen, setActionsMenuOpen] = useState({
76 presentation,
6877 scripts: false,
6978 imports: false,
7079 });
80 if (actionsMenuOpen.presentation !== presentation) {
81 setActionsMenuOpen({ presentation, scripts: false, imports: false });
82 }
7183 const [editorRequest, setEditorRequest] = useState<ProjectScriptEditorRequest | null>(null);
7284
7385 const primaryScript = useMemo(() => {
......@@ -97,7 +109,8 @@ export default function ProjectScriptsControl({
97109 };
98110
99111 const openEditDialog = (script: ProjectScript) => {
100 setActionsMenuOpen({ scripts: false, imports: false });
112 onRequestMenuClose?.();
113 setActionsMenuOpen({ presentation, scripts: false, imports: false });
101114 setEditorRequest(editorRequestForScript(script, keybindings));
102115 };
103116
......@@ -138,12 +151,13 @@ export default function ProjectScriptsControl({
138151 <MenuGroupLabel>From t3.json</MenuGroupLabel>
139152 {importableScripts.map((fileScript) => (
140153 <MenuItem
154 density={presentation === "menu" ? "touch" : "default"}
141155 key={`${fileScript.name} ${fileScript.command}`}
142156 className={dropdownItemClassName}
143157 onClick={() => void importFileScript(fileScript)}
144158 >
145159 <ScriptIcon icon={fileScript.icon ?? "play"} className="size-4" />
146 <span className="truncate">{fileScript.name}</span>
160 <MenuItemLabel className="truncate">{fileScript.name}</MenuItemLabel>
147161 <MenuShortcut className="ms-auto">
148162 <DownloadIcon className="size-3.5" aria-label="Import" />
149163 </MenuShortcut>
......@@ -153,9 +167,112 @@ export default function ProjectScriptsControl({
153167 </>
154168 );
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
156234 return (
157235 <>
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 ? (
159276 <Group aria-label="Project scripts">
160277 <Tooltip>
161278 <TooltipTrigger
......@@ -183,70 +300,25 @@ export default function ProjectScriptsControl({
183300 <Menu
184301 highlightItemOnHover={false}
185302 open={actionsMenuOpen.scripts}
186 onOpenChange={(open) => setActionsMenuOpen({ scripts: open, imports: false })}
303 onOpenChange={(open) =>
304 setActionsMenuOpen({ presentation, scripts: open, imports: false })
305 }
187306 >
188307 <MenuTrigger
189308 render={<Button size="icon-xs" variant="outline" aria-label="Script actions" />}
190309 >
191310 <ChevronDownIcon className="size-4" />
192311 </MenuTrigger>
193 <MenuPopup align="end">
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>
312 <MenuPopup align="end">{scriptItems}</MenuPopup>
243313 </Menu>
244314 </Group>
245315 ) : importableScripts.length > 0 ? (
246316 <Menu
247317 highlightItemOnHover={false}
248318 open={actionsMenuOpen.imports}
249 onOpenChange={(open) => setActionsMenuOpen({ scripts: false, imports: open })}
319 onOpenChange={(open) =>
320 setActionsMenuOpen({ presentation, scripts: false, imports: open })
321 }
250322 >
251323 <MenuTrigger render={<Button size="xs" variant="outline" aria-label="Project actions" />}>
252324 <PlusIcon className="size-3.5" />
apps/web/src/components/WorkspaceBreadcrumb.tsx+26-3
......@@ -1,4 +1,4 @@
1import type { ReactNode } from "react";
1import type { ComponentProps, ReactNode } from "react";
22
33import { cn } from "../lib/utils";
44
......@@ -26,6 +26,23 @@ interface WorkspaceBreadcrumbItemProps {
2626 readonly current?: boolean;
2727}
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
2946export function WorkspaceBreadcrumbItem({
3047 children,
3148 className,
......@@ -45,10 +62,16 @@ export function WorkspaceBreadcrumbItem({
4562 );
4663}
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}) {
4972 return (
5073 <li aria-hidden="true" className={cn("flex shrink-0 items-center text-icon-muted", className)}>
51 /
74 {children}
5275 </li>
5376 );
5477}
apps/web/src/components/chat/ChatHeader.tsx+125-39
......@@ -11,7 +11,7 @@ import {
1111 isAtomCommandInterrupted,
1212 squashAtomCommandFailure,
1313} from "@t3tools/client-runtime/state/runtime";
14import { ChevronDownIcon } from "lucide-react";
14import { ChevronDownIcon, EllipsisIcon } from "lucide-react";
1515import {
1616 memo,
1717 useCallback,
......@@ -22,6 +22,7 @@ import {
2222 type KeyboardEvent as ReactKeyboardEvent,
2323 type MouseEvent as ReactMouseEvent,
2424} from "react";
25import { createPortal } from "react-dom";
2526import GitActionsControl from "../GitActionsControl";
2627import { isTrailingDoubleClick } from "../Sidebar.logic";
2728import { type DraftId } from "~/composerDraftStore";
......@@ -45,8 +46,12 @@ import {
4546 WorkspaceBreadcrumb,
4647 WorkspaceBreadcrumbItem,
4748 WorkspaceBreadcrumbSeparator,
49 WorkspaceBreadcrumbText,
4850} from "../WorkspaceBreadcrumb";
4951import { cn } from "~/lib/utils";
52import { useIsMobile } from "~/hooks/useMediaQuery";
53import { Button } from "../ui/button";
54import { Menu, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/menu";
5055
5156interface ChatHeaderProps {
5257 activeThreadEnvironmentId: EnvironmentId;
......@@ -155,6 +160,40 @@ export const ChatHeader = memo(function ChatHeader({
155160 breakpoint: { value: HEADER_ACTIONS_EXPANDED_BREAKPOINT_REM, unit: "rem" },
156161 });
157162 }, [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);
158197 const primaryEnvironmentId = usePrimaryEnvironmentId();
159198 const activeProjectName = activeProject?.title;
160199 const activeProjectCwd = activeProject?.workspaceRoot ?? null;
......@@ -312,6 +351,50 @@ export const ChatHeader = memo(function ChatHeader({
312351 },
313352 [commitRename],
314353 );
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 );
315398 return (
316399 <div
317400 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({
339422 }
340423 >
341424 <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>
343428 </TooltipTrigger>
344429 <TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
345430 </Tooltip>
346431 </WorkspaceBreadcrumbItem>
347 <WorkspaceBreadcrumbSeparator />
432 <WorkspaceBreadcrumbSeparator>
433 <WorkspaceBreadcrumbText>/</WorkspaceBreadcrumbText>
434 </WorkspaceBreadcrumbSeparator>
348435 </>
349436 ) : null}
350437 <WorkspaceBreadcrumbItem current className="min-w-10 flex-1">
......@@ -377,7 +464,9 @@ export const ChatHeader = memo(function ChatHeader({
377464 />
378465 }
379466 >
380 <h2 className="min-w-0 truncate">{activeThreadTitle}</h2>
467 <h2 className="min-w-0">
468 <WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText>
469 </h2>
381470 <ChevronDownIcon
382471 aria-hidden
383472 data-thread-title-chevron
......@@ -389,12 +478,10 @@ export const ChatHeader = memo(function ChatHeader({
389478 ) : (
390479 <Tooltip>
391480 <TooltipTrigger
392 render={
393 <h2 aria-label={activeThreadTitle} className="min-w-0 flex-1 truncate">
394 {activeThreadTitle}
395 </h2>
396 }
397 />
481 render={<h2 aria-label={activeThreadTitle} className="min-w-0 flex-1" />}
482 >
483 <WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText>
484 </TooltipTrigger>
398485 <TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
399486 </Tooltip>
400487 )}
......@@ -405,38 +492,37 @@ export const ChatHeader = memo(function ChatHeader({
405492 data-chat-header-actions
406493 className={cn(
407494 "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)]",
409498 "[[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",
410499 )}
411500 >
412 {activeProjectScripts && (
413 <ProjectScriptsControl
414 scripts={activeProjectScripts}
415 fileScripts={fileScripts}
416 keybindings={keybindings}
417 preferredScriptId={preferredScriptId}
418 onRunScript={onRunProjectScript}
419 onAddScript={onAddProjectScript}
420 onUpdateScript={onUpdateProjectScript}
421 onDeleteScript={onDeleteProjectScript}
422 />
423 )}
424 {showOpenInPicker && (
425 <OpenInPicker
426 environmentId={activeThreadEnvironmentId}
427 keybindings={keybindings}
428 availableEditors={availableEditors}
429 openInCwd={openInCwd}
430 />
431 )}
432 {activeProjectName && (
433 <GitActionsControl
434 gitCwd={gitCwd}
435 activeThreadRef={scopeThreadRef(activeThreadEnvironmentId, activeThreadId)}
436 onOpenPullRequest={onOpenPullRequest}
437 {...(draftId ? { draftId } : {})}
438 />
439 )}
501 <Menu open={actionsCollapsed && actionsOpen} onOpenChange={setActionsOpen}>
502 <MenuTrigger
503 className={
504 actionsCollapsed &&
505 (activeProjectScripts || showOpenInPicker || (activeProjectName && gitCwd))
506 ? undefined
507 : "hidden"
508 }
509 render={<Button size="icon-sm" variant="ghost" aria-label="More header actions" />}
510 >
511 <EllipsisIcon className="size-4" />
512 </MenuTrigger>
513 <div ref={mountInlineActions} className="contents" />
514 <MenuPopup
515 data-chat-header-actions
516 keepMounted
517 aria-label="Header actions"
518 align="end"
519 className="min-w-56 max-w-[calc(100vw-2rem)]"
520 finalFocus={actionsCollapsed ? undefined : false}
521 >
522 <div ref={mountMenuActions} className="contents" />
523 {createPortal(headerActions, actionsContainer)}
524 </MenuPopup>
525 </Menu>
440526 </div>
441527 </div>
442528 );
apps/web/src/components/chat/OpenInPicker.tsx+78-23
......@@ -15,10 +15,20 @@ import {
1515 useRemoteOpenState,
1616} from "../../remoteOpen";
1717import { useEnvironment } from "../../state/environments";
18import { ChevronDownIcon, FolderClosedIcon } from "lucide-react";
18import { ChevronDownIcon, FolderClosedIcon, SquareArrowOutUpRightIcon } from "lucide-react";
1919import { Button } from "../ui/button";
2020import { 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";
2232import {
2333 AntigravityIcon,
2434 CursorIcon,
......@@ -187,6 +197,7 @@ export const OpenInPicker = memo(function OpenInPicker({
187197 keybindings,
188198 availableEditors,
189199 openInCwd,
200 presentation = "toolbar",
190201 compact = false,
191202 enableShortcut = true,
192203}: {
......@@ -194,6 +205,7 @@ export const OpenInPicker = memo(function OpenInPicker({
194205 keybindings: ResolvedKeybindingsConfig;
195206 availableEditors: ReadonlyArray<EditorId>;
196207 openInCwd: string | null;
208 presentation?: "toolbar" | "menu";
197209 compact?: boolean;
198210 enableShortcut?: boolean;
199211}) {
......@@ -274,6 +286,69 @@ export const OpenInPicker = memo(function OpenInPicker({
274286 return () => window.removeEventListener("keydown", handler);
275287 }, [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
277352 return (
278353 <Group aria-label="Open in editor">
279354 <Button
......@@ -307,27 +382,7 @@ export const OpenInPicker = memo(function OpenInPicker({
307382 >
308383 <ChevronDownIcon aria-hidden="true" className="size-4" />
309384 </MenuTrigger>
310 <MenuPopup align="end">
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>
385 <MenuPopup align="end">{editorItems}</MenuPopup>
331386 </Menu>
332387 </Group>
333388 );
apps/web/src/components/ui/menu.tsx+27-1
......@@ -25,6 +25,7 @@ function MenuPopup({
2525 alignOffset,
2626 side = "bottom",
2727 anchor,
28 keepMounted = false,
2829 ...props
2930}: MenuPrimitive.Popup.Props & {
3031 align?: MenuPrimitive.Positioner.Props["align"];
......@@ -32,6 +33,7 @@ function MenuPopup({
3233 alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
3334 side?: MenuPrimitive.Positioner.Props["side"];
3435 anchor?: MenuPrimitive.Positioner.Props["anchor"];
36 keepMounted?: boolean;
3537}) {
3638 const hasExplicitWidthClass =
3739 typeof className === "string" &&
......@@ -41,7 +43,7 @@ function MenuPopup({
4143 });
4244
4345 return (
44 <MenuPrimitive.Portal>
46 <MenuPrimitive.Portal keepMounted={keepMounted}>
4547 <MenuPrimitive.Positioner
4648 align={align}
4749 alignOffset={alignOffset}
......@@ -74,10 +76,12 @@ function MenuGroup(props: MenuPrimitive.Group.Props) {
7476function MenuItem({
7577 className,
7678 inset,
79 density = "default",
7780 variant = "default",
7881 ...props
7982}: MenuPrimitive.Item.Props & {
8083 inset?: boolean;
84 density?: "default" | "touch";
8185 variant?: "default" | "destructive" | "ghost";
8286}) {
8387 return (
......@@ -90,8 +94,10 @@ function MenuItem({
9094 size: "compact",
9195 className: "h-auto min-h-7 w-full sm:text-xs",
9296 }),
97 density === "touch" && "min-h-10 sm:min-h-10",
9398 className,
9499 )}
100 data-density={density}
95101 data-inset={inset}
96102 data-slot="menu-item"
97103 data-variant={variant}
......@@ -100,6 +106,21 @@ function MenuItem({
100106 );
101107}
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
103124function MenuCheckboxItem({
104125 className,
105126 children,
......@@ -247,10 +268,12 @@ function MenuSub(props: MenuPrimitive.SubmenuRoot.Props) {
247268function MenuSubTrigger({
248269 className,
249270 inset,
271 density = "default",
250272 children,
251273 ...props
252274}: MenuPrimitive.SubmenuTrigger.Props & {
253275 inset?: boolean;
276 density?: "default" | "touch";
254277}) {
255278 return (
256279 <MenuPrimitive.SubmenuTrigger
......@@ -261,8 +284,10 @@ function MenuSubTrigger({
261284 // also a direct svg — on a sub-trigger with no leading icon it is the
262285 // only one, and these rules would take away its `ms-auto` alignment.
263286 "[&>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",
264288 className,
265289 )}
290 data-density={density}
266291 data-inset={inset}
267292 data-slot="menu-sub-trigger"
268293 {...props}
......@@ -309,6 +334,7 @@ export {
309334 MenuGroup,
310335 MenuGroup as DropdownMenuGroup,
311336 MenuItem,
337 MenuItemLabel,
312338 MenuItem as DropdownMenuItem,
313339 MenuCheckboxItem,
314340 MenuCheckboxItem as DropdownMenuCheckboxItem,
apps/web/src/components/ui/sidebar.tsx+5-2
......@@ -155,7 +155,10 @@ function SidebarProvider({
155155 <SidebarContext value={contextValue}>
156156 <div
157157 // 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 )}
159162 data-sidebar-state={state}
160163 data-slot="sidebar-wrapper"
161164 style={
......@@ -337,7 +340,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
337340 variant="ghost"
338341 {...props}
339342 >
340 {isOpen ? <PanelLeftCloseIcon /> : <PanelLeftIcon />}
343 {isOpen ? <PanelLeftCloseIcon className="size-4" /> : <PanelLeftIcon className="size-4" />}
341344 <span className="sr-only">Toggle Sidebar</span>
342345 </Button>
343346 );