Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { requestCustomSnooze } from "./CustomSnoozeDialog";
import { ProjectFoldersDialog, type ProjectFoldersDialogTarget } from "./ProjectFoldersDialog";
import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
import { useAtomValue } from "@effect/atom-react";
Expand Down Expand Up @@ -2544,6 +2545,9 @@ export default function Sidebar() {
},
[isMobile, router, setOpenMobile],
);
const [projectFoldersTarget, setProjectFoldersTarget] =
useState<ProjectFoldersDialogTarget | null>(null);
const closeProjectFoldersDialog = useCallback(() => setProjectFoldersTarget(null), []);
// Anchor for the scope popup: the header search field, not its icon trigger.
const headerSearchRef = useRef<HTMLDivElement | null>(null);
// Safari can send a click after Ctrl+click opens settings. Ignore that one
Expand Down Expand Up @@ -4073,10 +4077,11 @@ export default function Sidebar() {
}
const thread = threadByKeyRef.current.get(threadKey);
if (!thread) return;
const threadWorkspacePath =
thread.worktreePath ??
projectByKey.get(`${thread.environmentId}:${thread.projectId}`)?.workspaceRoot ??
null;
// The physical project this thread lives in, not its group: a group
// can span checkouts on several environments.
const threadProject =
projectByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null;
const threadWorkspacePath = thread.worktreePath ?? threadProject?.workspaceRoot ?? null;
// Un-settle pins the thread active until real activity clears the pin.
// Environments without
// the settlement capability get no lifecycle items at all.
Expand Down Expand Up @@ -4117,6 +4122,7 @@ export default function Sidebar() {
isActive: projectScopeKey === threadProjectGroup.projectKey,
}
: null,
hasWorkspaceFile: Boolean(threadProject?.workspaceFile),
isPinned,
isSettled,
autoSettleEnabled: thread.autoSettleDisabledAt == null,
Expand Down Expand Up @@ -4161,6 +4167,16 @@ export default function Sidebar() {
case "project-settings":
if (threadProjectGroup) openProjectSettings(threadProjectGroup);
return;
case "manage-folders":
if (threadProject?.workspaceFile) {
setProjectFoldersTarget({
environmentId: threadProject.environmentId,
projectId: threadProject.id,
title: threadProject.title,
workspaceFile: threadProject.workspaceFile,
});
}
return;
case "new-thread-on-branch": {
// Explicit branch carry-over: reuse the thread's worktree when it
// has one, otherwise its branch on the local checkout.
Expand Down Expand Up @@ -5023,6 +5039,7 @@ export default function Sidebar() {
</SidebarGroup>
</SidebarContent>
<SidebarChromeFooter />
<ProjectFoldersDialog target={projectFoldersTarget} onClose={closeProjectFoldersDialog} />
</>
);
}
238 changes: 121 additions & 117 deletions apps/web/src/components/chat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import GitActionsControl from "../GitActionsControl";
import { ProjectFoldersDialog } from "../ProjectFoldersDialog";
import { isTrailingDoubleClick } from "../Sidebar.logic";
import { MultiRepoGitControl, type MultiRepoGitGroup } from "./MultiRepoGitControl";
import { type DraftId } from "~/composerDraftStore";
Expand Down Expand Up @@ -266,7 +267,7 @@ export const ChatHeader = memo(function ChatHeader({
},
[activeThreadEnvironmentId, activeThreadId, activeThreadTitle, updateThreadMetadata],
);
const { openMenu, closeMenu } = useThreadActionMenu({
const { openMenu, closeMenu, projectFoldersTarget, closeProjectFolders } = useThreadActionMenu({
threadRef: isServerThread ? activeThreadRef : null,
projectCwd: activeProjectCwd,
onStartRename: startRename,
Expand Down Expand Up @@ -421,135 +422,138 @@ export const ChatHeader = memo(function ChatHeader({
</>
);
return (
<div
className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"
onContextMenu={handleHeaderContextMenu}
>
<WorkspaceBreadcrumb
ariaLabel="Thread breadcrumb"
className="flex-1 overflow-clip [overflow-clip-margin:2px]"
<>
<div
className="@container/header-actions flex min-w-0 flex-1 items-center gap-2 sm:gap-3"
onContextMenu={handleHeaderContextMenu}
>
{/* The project always leads the header: knowing which project a
thread lives in is priority zero, and the thread title alone
doesn't answer it. */}
{activeProject ? (
<>
<WorkspaceBreadcrumbItem className="shrink">
<WorkspaceBreadcrumb
ariaLabel="Thread breadcrumb"
className="flex-1 overflow-clip [overflow-clip-margin:2px]"
>
{/* The project always leads the header: knowing which project a
thread lives in is priority zero, and the thread title alone
doesn't answer it. */}
{activeProject ? (
<>
<WorkspaceBreadcrumbItem className="shrink">
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={`New thread in ${activeProjectName}`}
onClick={onNewThreadInProject}
className="inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<ProjectFavicon project={activeProject} className="size-3.5" />
<WorkspaceBreadcrumbText className="max-w-40">
{activeProjectName}
</WorkspaceBreadcrumbText>
</TooltipTrigger>
<TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
</Tooltip>
</WorkspaceBreadcrumbItem>
<WorkspaceBreadcrumbSeparator>
<WorkspaceBreadcrumbText>/</WorkspaceBreadcrumbText>
</WorkspaceBreadcrumbSeparator>
</>
) : null}
<WorkspaceBreadcrumbItem current className="min-w-10 flex-1">
{renamingTitle !== null ? (
<input
autoFocus
aria-label="Thread title"
className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-ring/50 focus:ring-ring"
defaultValue={renamingTitle}
onBlur={(event) => {
if (renameCommittedRef.current) return;
commitRename(event.currentTarget.value);
}}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={handleRenameKeyDown}
/>
) : isServerThread ? (
<Tooltip>
<TooltipTrigger
render={
<button
ref={titleButtonRef}
type="button"
aria-label={`New thread in ${activeProjectName}`}
onClick={onNewThreadInProject}
className="inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Thread actions for ${activeThreadTitle}`}
aria-haspopup="menu"
onClick={openMenuFromTitle}
onDoubleClick={handleTitleDoubleClick}
onBlur={cancelPendingTitleMenu}
className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<ProjectFavicon project={activeProject} className="size-3.5" />
<WorkspaceBreadcrumbText className="max-w-40">
{activeProjectName}
</WorkspaceBreadcrumbText>
<h2 className="min-w-0">
<WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText>
</h2>
<ChevronDownIcon
aria-hidden
data-thread-title-chevron
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/thread-title:opacity-100 group-focus-visible/thread-title:opacity-100"
/>
</TooltipTrigger>
<TooltipPopup side="top">New thread in {activeProjectName}</TooltipPopup>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
</WorkspaceBreadcrumbItem>
<WorkspaceBreadcrumbSeparator>
<WorkspaceBreadcrumbText>/</WorkspaceBreadcrumbText>
</WorkspaceBreadcrumbSeparator>
</>
) : null}
<WorkspaceBreadcrumbItem current className="min-w-10 flex-1">
{renamingTitle !== null ? (
<input
autoFocus
aria-label="Thread title"
className="min-w-0 flex-1 rounded-sm bg-transparent text-sm font-medium text-foreground outline-none ring-1 ring-ring/50 focus:ring-ring"
defaultValue={renamingTitle}
onBlur={(event) => {
if (renameCommittedRef.current) return;
commitRename(event.currentTarget.value);
}}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={handleRenameKeyDown}
/>
) : isServerThread ? (
<Tooltip>
<TooltipTrigger
render={
<button
ref={titleButtonRef}
type="button"
aria-label={`Thread actions for ${activeThreadTitle}`}
aria-haspopup="menu"
onClick={openMenuFromTitle}
onDoubleClick={handleTitleDoubleClick}
onBlur={cancelPendingTitleMenu}
className="group/thread-title inline-flex min-w-0 max-w-full cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
/>
}
>
<h2 className="min-w-0">
) : (
<Tooltip>
<TooltipTrigger
render={<h2 aria-label={activeThreadTitle} className="min-w-0 flex-1" />}
>
<WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText>
</h2>
<ChevronDownIcon
aria-hidden
data-thread-title-chevron
className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/thread-title:opacity-100 group-focus-visible/thread-title:opacity-100"
/>
</TooltipTrigger>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger
render={<h2 aria-label={activeThreadTitle} className="min-w-0 flex-1" />}
>
<WorkspaceBreadcrumbText>{activeThreadTitle}</WorkspaceBreadcrumbText>
</TooltipTrigger>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
</TooltipTrigger>
<TooltipPopup side="top">{activeThreadTitle}</TooltipPopup>
</Tooltip>
)}
</WorkspaceBreadcrumbItem>
</WorkspaceBreadcrumb>
<div
ref={headerActionsRef}
data-chat-header-actions
className={cn(
"flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3",
// Reserve two panel toggles plus their 4px gaps and 1px edge inset.
// The page header adds 8px more right padding at sm.
rightPanelOpen ? "pr-0" : "pr-18.25 sm:pr-14.25",
"[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-out",
)}
</WorkspaceBreadcrumbItem>
</WorkspaceBreadcrumb>
<div
ref={headerActionsRef}
data-chat-header-actions
className={cn(
"flex shrink-0 items-center justify-end gap-2 @3xl/header-actions:gap-3",
// Reserve two panel toggles plus their 4px gaps and 1px edge inset.
// The page header adds 8px more right padding at sm.
rightPanelOpen ? "pr-0" : "pr-18.25 sm:pr-14.25",
"[[data-panel-animations=true]_&]:motion-safe:transition-[padding-right] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-out",
)}
>
<Menu open={actionsCollapsed && actionsOpen} onOpenChange={setActionsOpen}>
<MenuTrigger
className={
actionsCollapsed &&
(activeProjectScripts ||
showOpenInPicker ||
(activeProjectName && (gitCwd || repoStatusGroups.length > 1)))
? undefined
: "hidden"
}
render={<Button size="icon-sm" variant="ghost" aria-label="More header actions" />}
>
<EllipsisIcon className="size-4" />
</MenuTrigger>
<div ref={mountInlineActions} className="contents" />
<MenuPopup
data-chat-header-actions
keepMounted
aria-label="Header actions"
align="end"
finalFocus={actionsCollapsed ? undefined : false}
>
<div ref={mountMenuActions} className="contents" />
{createPortal(headerActions, actionsContainer)}
</MenuPopup>
</Menu>
>
<Menu open={actionsCollapsed && actionsOpen} onOpenChange={setActionsOpen}>
Comment thread
LoganRupe marked this conversation as resolved.
<MenuTrigger
className={
actionsCollapsed &&
(activeProjectScripts ||
showOpenInPicker ||
(activeProjectName && (gitCwd || repoStatusGroups.length > 1)))
? undefined
: "hidden"
}
render={<Button size="icon-sm" variant="ghost" aria-label="More header actions" />}
>
<EllipsisIcon className="size-4" />
</MenuTrigger>
<div ref={mountInlineActions} className="contents" />
<MenuPopup
data-chat-header-actions
keepMounted
aria-label="Header actions"
align="end"
finalFocus={actionsCollapsed ? undefined : false}
>
<div ref={mountMenuActions} className="contents" />
{createPortal(headerActions, actionsContainer)}
</MenuPopup>
</Menu>
</div>
</div>
</div>
<ProjectFoldersDialog target={projectFoldersTarget} onClose={closeProjectFolders} />
</>
);
});
11 changes: 11 additions & 0 deletions apps/web/src/components/threadActionMenu.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./thread
const baseState: ThreadActionMenuState = {
branch: null,
projectFilter: null,
hasWorkspaceFile: false,
isPinned: false,
isSettled: false,
autoSettleEnabled: true,
Expand Down Expand Up @@ -82,6 +83,16 @@ describe("buildThreadActionMenuItems", () => {
expect(items[filterIndex + 1]?.id).toBe("auto-settle");
});

it("offers Manage folders next to project settings only for workspace-file projects", () => {
expect(ids(baseState)).not.toContain("manage-folders");
const items = buildThreadActionMenuItems({ ...baseState, hasWorkspaceFile: true });
const settingsIndex = items.findIndex((item) => item.id === "project-settings");
expect(items[settingsIndex + 1]).toMatchObject({
id: "manage-folders",
label: "Manage folders...",
});
});

it("includes branch items only for threads with a branch", () => {
const withBranch = allIds({ ...baseState, branch: "feat/menu" });
expect(withBranch).toContain("new-thread-on-branch");
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/threadActionMenu.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type ThreadActionMenuId =
| "new-thread-on-branch"
| "filter-by-project"
| "project-settings"
| "manage-folders"
| "pin"
| "unpin"
| "settle"
Expand Down Expand Up @@ -41,6 +42,11 @@ export interface ThreadActionMenuState {
/** True when the list is already scoped to this thread's project. */
readonly isActive: boolean;
} | null;
/**
* True when the thread's project is backed by a `.code-workspace` file.
* Plain-folder projects have no folder list to manage.
*/
readonly hasWorkspaceFile: boolean;
readonly isPinned: boolean;
readonly isSettled: boolean;
/** False while the user has turned automatic settlement off for this thread. */
Expand Down Expand Up @@ -177,6 +183,9 @@ export function buildThreadActionMenuItems(
],
},
{ id: "project-settings", label: "Project settings", icon: "settings" },
...(state.hasWorkspaceFile
? [{ id: "manage-folders" as const, label: "Manage folders...", icon: "folder" }]
: []),
// Archive removes the thread from the sidebar while keeping its
// conversation under Settings > Archived threads — distinct from Settle
// (stays visible in the Settled shelf) and Delete (clears history for
Expand Down
Loading
Loading