Skip to content

Commit 1f2c093

Browse files
authored
feat(v2): minimal project create/import for workspaces (#3566)
* docs: v2 project create/import design + plan Simplified redesign after PR review. Collapses the earlier three-signal backing model (cloud + per-host cloud signal + local) into two signals (cloud + local-only), removes the v2_host_projects cloud table and Electric sync, drops per-row state decoration on the sidebar, and moves backing checks to action time (workspace-create modal, error paths). * feat(trpc): v2Projects.findByGitHubRemote + jwt-scoped create Adds the cloud-side matcher used by host-service's folder-first import flow: given a clone URL, returns candidate projects the user has access to whose GitHub repo matches (case-insensitively). Named findByGitHubRemote (not findByRemote) because the match is GitHub-specific. v2Projects.create switches to jwtProcedure with an explicit organizationId + repoCloneUrl, matching the shape host-service needs to call from project.create. No existing callers. parseGitHubRemote moves from packages/host-service to packages/shared so both cloud tRPC and host-service consume the same implementation. * feat(host-service): project.create / setup / list / findByPath / remove Full create/import lifecycle in host-service: - project.list — DB read of host-service.projects. Pure, no filesystem probing. Stale paths surface via operation errors, not proactive checks. - project.findByPath — validate git root, read remote, forward to cloud v2Projects.findByGitHubRemote. Backs the folder-first import picker. - project.create — discriminated-union mode (empty/clone/importLocal/ template); Phase 1 ships clone + importLocal only, empty and template throw NOT_IMPLEMENTED. - project.setup — discriminated-union mode (clone/import) with acknowledgeWorkspaceInvalidation gate on the re-point case. - project.remove — local worktree + repo dir teardown. Cloud backing (v2_host_projects) is intentionally absent: there is no per-host cloud signal in this design. Backing is a local-only concept, checked at action time. Adds ProjectNotSetupCause to the error formatter so the renderer can catch throws from workspace.create (next commit) and open the Pin & Set Up modal inline. * feat(desktop): add-repository modals at dashboard layout level Three flows for getting projects onto this device: - New project — clone a GitHub URL into a chosen parent directory. Drives project.create(mode=clone). - Import existing folder — native picker → project.findByPath branches on candidate count. 0 → name + create (importLocal). 1, not set up here → auto-advance to project.setup. 1, already set up → destructive re-point confirmation. >1 → picker modal. - Pin & set up — clone an existing cloud project onto this device. Drives project.setup(mode=clone), with forceRepoint entry for repair. All three modals are mounted once at the dashboard layout level via AddRepositoryModals, and opened through a small zustand store. Sidebar header "Add repository" dropdown triggers New project / Import folder. * feat(desktop): workspaces-tab Available section + folder-first import trigger Lists cloud projects in the user's active org that aren't pinned locally. Pin & set up per row runs project.setup. Header dropdown ("Add repository") mirrors the sidebar — "+ New project" + "Import existing folder." Entry points route through the dashboard-level AddRepositoryModals via the shared zustand store. useAvailableV2Projects powers the section: antijoin v2Projects ∖ v2SidebarProjects scoped to the active organization, with the existing v2-workspaces search filter applied. * feat(desktop): workspace-create inline setup + remote-device stub - Host-service workspaceCreation.{create,checkout,adopt} throw PROJECT_NOT_SETUP (PRECONDITION_FAILED + cause { kind, projectId }) when this host has no local project row. No more silent auto-clone into ~/.superset/repos/ — the user explicitly picks where to clone. - Pending workspace-create page intercepts data.projectNotSetup on the error, opens the Pin & set up modal pre-filled with the project, and registers a one-shot onSuccess callback to retry the original intent once setup resolves. The pending row stays in "creating" through the modal so the UI doesn't flicker to failed. - Clicking a remote-device workspace row lands on the new WorkspaceNotOnThisHostState stub: explains the workspace lives on another host, offers "Set up here" (opens Pin & set up for the project) or "Browse workspaces." V2 workspace page checks host.machineId via live query and renders the stub before mounting the pane tree, which would otherwise crash on a foreign worktree. * Fix infinite import * fix: pre-existing notification test, a11y labels, design doc shape - notification-manager.test: update expected strings to match source (strings changed in #3039; test wasn't updated, CI was red on main too) - DashboardSidebarHeader: aria-label="Add repository" on icon-only dropdown triggers so screen readers announce them (tooltips don't count as accessible names) - docs/design/v2-project-create-import: correct v2Projects.create input shape (jwt-scoped { organizationId, name, slug, repoCloneUrl }) * feat(desktop): unify new-workspace pickers + link popovers, strip chat link UI - Unify DevicePicker / ProjectPickerPill / CompareBaseBranchPicker to a shared FORM_PICKER_TRIGGER_CLASS: no background, h-[22px], text-[11px] text-muted-foreground, size-3 icons, align="start" dropdowns. Bump the project trigger thumbnail to size-4; drop the leftover `!` override (twMerge handles it). - DevicePicker: icon-only trigger (aria-label + title surface the name). - Rewrite IssueLinkCommand / PRLinkCommand / GitHubIssueLinkCommand to one codepath each: accept a button as `children`, wrap it in PopoverTrigger, own their open state internally. No more shared plusMenuRef, no more external open/onOpenChange/anchorRef coordination, no manual onPointerDownOutside anchor-guard — Radix handles toggle and dismiss natively so clicking a trigger while its popover is open closes it like every other picker. - v2 NewWorkspace PromptGroup: drop the three popover-open useStates + plusMenuRef + manual toggle handlers. AttachmentButtons becomes a layout shell that renders the three trigger elements as props; each wraps a shared LinkTrigger (tooltip + pill button). - Chat (v1 + v2) + v1 NewWorkspace PromptGroup: remove the link-issue popover wiring (IssueLinkCommand usage, ChatShortcuts' onLinkIssue callback). PlusMenu in chat collapses from a dropdown with attach/link options to a plain attachment button. - Temporarily disable v2 ChatPane render: it predates this PR and is missing ChatServiceProvider (introduced in PR #3088), so chatServiceTrpc has no context in TiptapPromptEditor. Replaced with a "Chat pane is temporarily disabled" placeholder; original render body commented out for quick restoration. * feat(desktop): host-scoped project picker with Available / Needs setup sections The v2 new-workspace picker was listing every cloud project the user had access to, regardless of whether it was set up on the selected device. That produced the PROJECT_NOT_SETUP error path on submit — reviewers flagged the pending-row-stuck-in-creating fallout as a P1. Root-cause fix: split the project list by selected-host availability. - `useHostProjectIds(hostTarget)` queries host-service `project.list` on the chosen device (local via activeHostUrl, remote via relay) and returns the set of set-up project IDs. - PromptGroup splits `recentProjects` into `availableProjects` + `needSetupProjects` using that set; changing the device refetches. - ProjectPickerPill renders two CommandGroup sections: Available (click selects) and Needs setup (click opens Pin & set up for that project). - Pin & set up already invalidates `["project", "list", activeHostUrl]` on success, so after setup the project flips to Available — user picks it and continues normally. While `project.list` is loading or errors, everything falls back to Available — picker stays usable; any real failure surfaces via the existing workspace-create error path. * lint * fix(desktop): IssueLinkCommand uncontrolled close + PlusMenu aria-label - IssueLinkCommand: the refactored popover-trigger API made `open` and `onOpenChange` optional so callers (v2 PromptGroup) could let Radix manage state. But `handleSelect` only fired the optional controlled callback, so in uncontrolled mode the popover never closed after picking an issue. Track state ourselves via a controllable-state pattern: internal `useState` when the prop is absent, caller's value when passed. `setOpen` always writes through, so close-on-select works in both modes. - PlusMenu: add aria-label="Add attachment" to the icon-only trigger. Radix Tooltip sets aria-describedby on the trigger, not aria-labelledby, so screen readers previously announced it as an unlabeled button. * refactor(desktop): drop controlled-open props from IssueLinkCommand; extend aria-label fix - IssueLinkCommand: only caller passes `onSelect + children`, so the optional open/onOpenChange pass-through was dead code. Simplify to always-internal state. Radix Popover has no imperative close from inside its content — owning state is the canonical shadcn/cmdk pattern, not scaffolding. - AttachmentButtons (v2): add aria-label to the shared LinkTrigger (so Link issue / Link GitHub issue / Link pull request all announce a name) and to the paperclip. Same fix as PlusMenu — Radix Tooltip sets aria-describedby on the trigger, not aria-labelledby, so tooltip-only buttons read as unlabeled to screen readers. * fix(trpc): scope v2Project.findByGitHubRemote + modal picker to active org host-service is pinned to a single organization at boot (env.ORGANIZATION_ID); its local projects table has no orgId column. Project discovery was leaking across orgs: - v2Project.findByGitHubRemote used ctx.organizationIds (plural, all accessible orgs). The folder-first picker would surface candidates from orgs the current host can't set up, producing a confusing NOT_FOUND when host-service then called v2Project.get with its own org. - DashboardNewWorkspaceModalContent queried collections.v2Projects with no org filter. Same over-fetch, same downstream failure. Align both with the rest of the codebase (v2Project.get / create, useAvailableV2Projects, useWorkspaceHostOptions) which take/filter by an explicit active orgId: - findByGitHubRemote: add organizationId input, authorize it against ctx.organizationIds (same shape as get/create), filter candidates by it. - host-service project.findByPath: pass ctx.organizationId through. - DashboardNewWorkspaceModalContent: .where(eq(projects.organizationId, activeOrganizationId)) on the live query, matching useAvailableV2Projects. * Lint * fix(host-service): clone-then-cloud in project.createFromClone, rollback on cloud failure Matches the local-first-then-cloud pattern already used by workspace.create (workspace-creation.ts:860-918, which git-worktree-adds first then registers cloud with a rollback on failure). Previously createFromClone called v2Project.create before cloneRepoInto, so any clone failure (network, bad URL, auth, dir collision) left a cloud v2_projects row with nothing local backing it on any host. Retrying the flow with corrected input accumulated more orphans. Reorder: clone first, register cloud in try/catch, rmSync the freshly- created clone if cloud-create or persistLocalProject throws. * fix(host-service): move project.create visibility into GitHub-provisioning modes Only empty + template modes provision a new GitHub repo and need to tell the GitHub App whether it should be private or public. clone + importLocal reuse an existing remote where visibility is already set — the top-level field was required but ignored for those two paths. Move `visibility: z.enum(["private", "public"])` into the empty and template variants of the discriminated union. Drop it from clone/ importLocal callers. Update design doc to match. * refactor(desktop): use Radix composition for link-command tooltips, drop dead chat-link wiring Responds to saddlepaddle + Kitenite reviews on PR #3566. - IssueLinkCommand / PRLinkCommand / GitHubIssueLinkCommand now own the Popover + Tooltip composition internally via `PopoverTrigger asChild > TooltipTrigger asChild`. Callers pass a plain PromptInputButton + a tooltipLabel prop. Removes the LinkTrigger forwardRef + `{...rest}` spread trick that was sneaking Popover props through an intermediate Tooltip wrapper. - Delete the misleading JSDoc at IssueLinkCommand claiming Radix can't be closed imperatively — PopoverClose exists; the controlled-open pattern we use is just shadcn's canonical combobox. - Drop orphaned `_issueLinkOpen` / `_addLinkedIssue` + the `setIssueLinkOpen` prop threaded through ChatShortcuts in both v1 and v2 chat, plus the same dead state in v1 NewWorkspaceModal PromptGroup. - Retire the CHAT_LINK_ISSUE hotkey entry — its only consumer was the dead setIssueLinkOpen toggle. * chore: trim past-state narration + what-describing comments - Drop "previously this did X" / "introduced in PR #3088" / commented-out renderPane block in v2 usePaneRegistry chat pane. - Collapse JSDocs that only restated the function's name (ParentDirectoryPicker, AddRepositoryModals layout blurb, per-method docs on UseFolderFirstImportResult, persistLocalProject). - Tighten the explanatory comments that still earn their keep (pending PROJECT_NOT_SETUP interceptor, PinAndSetupModal conflict state, store onSuccess / forceRepoint prop docs). * refactor(desktop): strip v2 discovery/recovery surface to MVP Two rules for v1: - Sidebar = pinned projects. - Workspaces tab = every workspace in the user's active org. Code deletes: - V2AvailableProjectsSection, useAvailableV2Projects, useHostProjectIds. - v2UsersHosts innerJoin in useAccessibleV2Workspaces — tab no longer drops rows when host access changes. - Available-section wiring in V2WorkspacesList + v2-workspaces/page.tsx. - Available / Needs-setup split in ProjectPickerPill and the openPinAndSetup bridge in PromptGroup. Also removes the wrong-host bug (cubic AF_o, saddlepaddle CXVQ) as dead code. - PROJECT_NOT_SETUP recovery loop in the pending page — failure is a plain toast now. Docs realigned: - design/v2-project-create-import.md opens with the two rules and moves Available / inline setup / backing signals to an explicit "Out of scope for v1" block. - plans/20260417-v2-project-create-import-impl.md mirrors the same deferrals; Phase-1 checklist is now all checked. Net −537 lines. Typecheck + lint clean. * refactor(desktop): open remote-host workspaces without gating Previously any workspace whose hostMachineId didn't match the local machine landed on a WorkspaceNotOnThisHostState stub. That hid the workspace from the user entirely when the whole point is to let them see it. Delete the gate, delete the stub component, and let the workspace page render for any host. Operations that assume local filesystem (terminal spawn, local git) fail at the point they run. Also slims the page's live query — projectGithubOwner, projectName, hostMachineId etc. were only fed into the stub. Design doc + plan updated to reflect the no-gating posture. Resolves saddlepaddle CTvC. * refactor(desktop): extract FormPickerTrigger component Addresses saddlepaddle CWlq — the shared style for the three top-of-modal pickers (Device / Project / Branch) lived as a string constant in types.ts, which is an odd place for a className and doesn't compose. Promote it to a named FormPickerTrigger component that encapsulates the base button styles and accepts extra className + native button props. The three call sites lose their raw <button type="button"> + backtick-composed classNames. Drops FORM_PICKER_TRIGGER_CLASS from types.ts. * refactor(desktop): remove dead PinAndSetupModal + async-hygiene sweep PinAndSetupModal had zero remaining callers after the MVP cut — the pending-page PROJECT_NOT_SETUP interceptor and the Available-section "Pin & set up" button were the only two. Delete the whole modal, its store action, useOpenPinAndSetupModal hook, PinAndSetupTarget type, and the forceRepoint plumbing that existed only to support it. Also addresses the async-hygiene nits on the surviving surfaces: - useFolderFirstImport.start wraps selectDirectory.mutateAsync in try/catch → reportError (coderabbit nmS, cubic op5). - ParentDirectoryPicker.handleBrowse wraps the same (cubic op8). - AddRepositoryModals effect adds .catch on startRef.current() (cubic oqE). - FolderFirstImportModal keys CandidatePickerContent on repoPath so selectedId resets per import (coderabbit nmM). Docs + plan updated to reflect the removed modal + ENOENT recovery deferral. Net −205 lines. Typecheck + lint clean. * refactor(host-service): reject re-pointing instead of confirming it v1 has no re-point UX. project.setup now treats an existing row as: - same resolved path → no-op success (idempotent; fixes the false CONFLICT that cubic/coderabbit flagged on same-path setup). - different path → CONFLICT with the existing path in the message, no escape hatch. User must project.remove first if they genuinely want to move the project. Drops `acknowledgeWorkspaceInvalidation` from the input, the ack branch of the CONFLICT guard, and the setupFromClone/setupFromImport helpers + SetupContext type in handlers.ts (the setup path is small enough to inline). Client drops the confirm-repoint state, confirmRepoint method, ConfirmRepointContent component, and the conflict branch in SetupInvokeResult — none of which have anything to retry against. Also fixes the TOCTOU race in cloneRepoInto: replaces existsSync + rmSync-on-error with mkdirSync (atomic claim) + rmSync-on-error, so clone failure can't delete a directory this process didn't create. Resolves coderabbit nmb, nmd, and cubic oqN. * fix(desktop): unify workspaces-tab empty state The onboarding "No workspaces yet" check was reading already-filtered pinned/others counts, so a search that matched nothing landed on the onboarding copy instead of the clear-filters UI. Collapse to a single !hasAnyMatches branch that picks copy + icon based on hasActiveFilters. Drops the bogus hasAnyWorkspaces check. Resolves cubic CrwE. * revert queries * Clean up dead code * refactor(desktop): port v1 new-project UI into NewProjectModal Replace the bespoke name + clone-URL + parent-picker form with v1's new-project page layout: a Location row (text input + browse button), three mode tiles (Clone/Empty/Template), and a per-mode form. Only Clone is wired up; Empty + Template carry "(coming soon)" since v2 project.create throws NOT_IMPLEMENTED for them. Location auto-populates to ~/.superset/projects via window.getHomeDir. Project name is derived from the clone URL's last segment so the form matches v1 (no explicit name field). ParentDirectoryPicker deleted — the inline Input + folder button replaces it and there's no other caller. * feat(db/trpc): decouple v2 projects from GitHub App installs v2Projects previously required a non-null githubRepositoryId, which gated project creation on the org having installed the repo via the GitHub App. Cloning any other repo (public, not installed, or non- matching) failed at the cloud step after a successful local clone. Changes: - githubRepositoryId becomes nullable with ON DELETE SET NULL, matching v1's projects table. - repoCloneUrl is added as the canonical source of truth for the remote URL. Also nullable so empty-mode / local-only projects without a remote can coexist. - UNIQUE(organization_id, lower(repo_clone_url)) prevents two projects from claiming the same repo in one org. NULLs don't collide, so URL-less projects still work. - v2Project.create accepts an optional repoCloneUrl, canonicalizes via parseGitHubRemote, and links a matching github_repositories row case-insensitively when one exists. Unique-violation (23505) surfaces as CONFLICT with per-constraint messaging. - v2Project.findByGitHubRemote matches on v2Projects.repoCloneUrl directly instead of joining through the installation table, so unlinked projects are discoverable. - v2Project.get drops the derived repoCloneUrl — consumers read the stored column or the joined githubRepository directly. Migration 0034 bundles all five schema changes. Nullable-safe: no backfill required for existing rows. * No candidate thing * feat(desktop): flag projects not set up on selected host in new-workspace modal After picking a host in DevicePicker, each project in ProjectPickerPill shows an amber warning triangle when that host doesn't have the project set up locally. A matching "Project needs to be set up" note appears next to the ⌘↵ hint when the currently-selected project needs setup, so the user sees the blocker before submitting. Setup state comes from a per-host project.list query (re-added to the host-service router). The RPC is resolved through the standard getHostServiceClientByUrl path — local uses activeHostUrl, remote/cloud goes through the relay. If the host is unreachable we treat setup as unknown and hide the indicator rather than falsely flagging everything. Submit path is unchanged: picking a not-set-up project still fires workspace.create, which throws PROJECT_NOT_SETUP and surfaces as the existing toast. Inline setup UX is still deferred. * chore: biome format fix + sync design doc to workspaces-tab filter - git.ts: biome wants the ghMsg ternary wrapped; main's 27e243b added the catch block and the CI biome check caught it post-merge. - design doc: the workspaces tab code filters to hosts the user is linked to via v2_users_hosts, not every workspace in the org. Update wording to match what shipped; note teammate workspaces on unshared hosts are not surfaced in v1. * docs: move v2 project create/import plan to plans/done Plan is shipped — move per AGENTS.md rule 7 and drop the rewrite/history notes in both plan and design doc since the PR body is the canonical record of what was cut. * docs: drop rewrite/history notes from plan and design doc Captured in PR body instead. * chore: trim restating/navigational comments in project handlers
1 parent ae930df commit 1f2c093

59 files changed

Lines changed: 7314 additions & 811 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1791,7 +1791,8 @@ export async function createWorktreeFromPr({
17911791
{ cwd: worktreePath, timeout: 120_000 },
17921792
);
17931793
} catch (ghError) {
1794-
const ghMsg = ghError instanceof Error ? ghError.message : String(ghError);
1794+
const ghMsg =
1795+
ghError instanceof Error ? ghError.message : String(ghError);
17951796
// `gh pr checkout` can fail with "is not a branch" when the branch name
17961797
// contains '/' (e.g. "user/feature-branch"). Git has trouble resolving
17971798
// "origin/user/feature-branch" as a tracking ref inside a worktree.

apps/desktop/src/main/lib/notifications/notification-manager.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,8 @@ describe("NotificationManager", () => {
297297

298298
expect(createNotification).toHaveBeenCalledWith(
299299
expect.objectContaining({
300-
title: "Input Needed — Test Workspace",
301-
body: '"Test Title" needs your attention',
300+
title: "Awaiting Response — Test Workspace",
301+
body: '"Test Title" is waiting for your reply',
302302
}),
303303
);
304304
});

apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { useFocusPromptOnPane } from "renderer/components/Chat/ChatInterface/hoo
1414
import { useHotkeyDisplay } from "renderer/hotkeys";
1515
import type { SlashCommand } from "../../hooks/useSlashCommands";
1616
import type { ModelOption, PermissionMode } from "../../types";
17-
import { IssueLinkCommand } from "../IssueLinkCommand";
1817
import { TiptapPromptEditor } from "../TiptapPromptEditor";
1918
import { ChatComposerControls } from "./components/ChatComposerControls";
2019
import { ChatInputDropZone } from "./components/ChatInputDropZone";
@@ -100,23 +99,12 @@ export function ChatInputFooter({
10099
}
101100
}, [pendingQuestion, textInput]);
102101

103-
const [issueLinkOpen, setIssueLinkOpen] = useState(false);
104102
const [linkedIssues, setLinkedIssues] = useState<LinkedIssue[]>([]);
105103
const inputRootRef = useRef<HTMLDivElement>(null);
106104
const errorMessage = getErrorMessage(error);
107105
const focusShortcutText = useHotkeyDisplay("FOCUS_CHAT_INPUT").text;
108106
const showFocusHint = focusShortcutText !== "Unassigned";
109107

110-
const addLinkedIssue = useCallback(
111-
(slug: string, title: string, taskId: string | undefined, url?: string) => {
112-
setLinkedIssues((prev) => {
113-
if (prev.some((issue) => issue.slug === slug)) return prev;
114-
return [...prev, { slug, title, taskId, url }];
115-
});
116-
},
117-
[],
118-
);
119-
120108
const removeLinkedIssue = useCallback((slug: string) => {
121109
setLinkedIssues((prev) => prev.filter((issue) => issue.slug !== slug));
122110
}, []);
@@ -176,15 +164,7 @@ export function ChatInputFooter({
176164
maxFileSize={10 * 1024 * 1024}
177165
globalDrop
178166
>
179-
<ChatShortcuts
180-
isFocused={isFocused}
181-
setIssueLinkOpen={setIssueLinkOpen}
182-
/>
183-
<IssueLinkCommand
184-
open={issueLinkOpen}
185-
onOpenChange={setIssueLinkOpen}
186-
onSelect={addLinkedIssue}
187-
/>
167+
<ChatShortcuts isFocused={isFocused} />
188168
<FileDropOverlay visible={dragType === "files"} />
189169
<PromptInputAttachments>
190170
{renderAttachment ??
@@ -217,7 +197,6 @@ export function ChatInputFooter({
217197
submitStatus={submitStatus}
218198
submitDisabled={submitDisabled}
219199
onStop={onStop}
220-
onLinkIssue={() => setIssueLinkOpen(true)}
221200
/>
222201
</PromptInput>
223202
</div>

apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatComposerControls/ChatComposerControls.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ interface ChatComposerControlsProps {
3030
submitStatus?: ChatStatus;
3131
submitDisabled?: boolean;
3232
onStop: (event: React.MouseEvent) => void;
33-
onLinkIssue: () => void;
3433
}
3534

3635
export function ChatComposerControls({
@@ -47,7 +46,6 @@ export function ChatComposerControls({
4746
submitStatus,
4847
submitDisabled,
4948
onStop,
50-
onLinkIssue,
5149
}: ChatComposerControlsProps) {
5250
return (
5351
<PromptInputFooter>
@@ -70,7 +68,7 @@ export function ChatComposerControls({
7068
/>
7169
</PromptInputTools>
7270
<div className="flex items-center gap-2">
73-
<PlusMenu onLinkIssue={onLinkIssue} />
71+
<PlusMenu />
7472
<PromptInputSubmit
7573
className="size-[23px] rounded-full border border-transparent bg-foreground/10 shadow-none p-[5px] hover:bg-foreground/20"
7674
status={submitStatus}

apps/desktop/src/renderer/components/Chat/ChatInterface/components/ChatInputFooter/components/ChatShortcuts/ChatShortcuts.tsx

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,13 @@ import {
22
usePromptInputAttachments,
33
usePromptInputController,
44
} from "@superset/ui/ai-elements/prompt-input";
5-
import type React from "react";
65
import { useHotkey } from "renderer/hotkeys";
76

87
interface ChatShortcutsProps {
98
isFocused: boolean;
10-
setIssueLinkOpen: React.Dispatch<React.SetStateAction<boolean>>;
119
}
1210

13-
export function ChatShortcuts({
14-
isFocused,
15-
setIssueLinkOpen,
16-
}: ChatShortcutsProps) {
11+
export function ChatShortcuts({ isFocused }: ChatShortcutsProps) {
1712
const attachments = usePromptInputAttachments();
1813
const { textInput } = usePromptInputController();
1914

@@ -25,14 +20,6 @@ export function ChatShortcuts({
2520
{ enabled: isFocused, preventDefault: true },
2621
);
2722

28-
useHotkey(
29-
"CHAT_LINK_ISSUE",
30-
() => {
31-
setIssueLinkOpen((prev) => !prev);
32-
},
33-
{ enabled: isFocused, preventDefault: true },
34-
);
35-
3623
useHotkey(
3724
"FOCUS_CHAT_INPUT",
3825
() => {
Lines changed: 88 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import {
22
Command,
3-
CommandDialog,
43
CommandEmpty,
54
CommandGroup,
65
CommandInput,
76
CommandItem,
87
CommandList,
98
} from "@superset/ui/command";
10-
import { Popover, PopoverAnchor, PopoverContent } from "@superset/ui/popover";
9+
import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover";
10+
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
1111
import { useLiveQuery } from "@tanstack/react-db";
1212
import Fuse from "fuse.js";
13-
import type React from "react";
14-
import type { RefObject } from "react";
13+
import type { ReactNode } from "react";
1514
import { useMemo, useState } from "react";
1615
import {
1716
StatusIcon,
@@ -21,22 +20,23 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect
2120

2221
const MAX_RESULTS = 20;
2322

24-
type IssueLinkCommandProps = {
25-
open: boolean;
26-
onOpenChange: (open: boolean) => void;
23+
interface IssueLinkCommandProps {
24+
children: ReactNode;
25+
tooltipLabel: string;
2726
onSelect: (
2827
slug: string,
2928
title: string,
3029
taskId: string | undefined,
3130
url?: string,
3231
) => void;
33-
} & (
34-
| { variant?: "dialog" }
35-
| { variant: "popover"; anchorRef: RefObject<HTMLElement | null> }
36-
);
32+
}
3733

38-
export function IssueLinkCommand(props: IssueLinkCommandProps) {
39-
const { open, onOpenChange, onSelect } = props;
34+
export function IssueLinkCommand({
35+
children,
36+
tooltipLabel,
37+
onSelect,
38+
}: IssueLinkCommandProps) {
39+
const [open, setOpen] = useState(false);
4040
const [searchQuery, setSearchQuery] = useState("");
4141
const collections = useCollections();
4242

@@ -108,115 +108,93 @@ export function IssueLinkCommand(props: IssueLinkCommandProps) {
108108
.map((r) => r.item);
109109
}, [allTasks, searchQuery, taskFuse]);
110110

111-
const handleClose = () => {
112-
setSearchQuery("");
113-
onOpenChange(false);
114-
};
115-
116111
const handleSelect = (
117112
slug: string,
118113
title: string,
119114
taskId: string | undefined,
120115
url?: string,
121116
) => {
122117
onSelect(slug, title, taskId, url);
123-
handleClose();
118+
setSearchQuery("");
119+
setOpen(false);
124120
};
125121

126-
const issueListContent = (
127-
<>
128-
<CommandInput
129-
placeholder="Search issues..."
130-
value={searchQuery}
131-
onValueChange={setSearchQuery}
132-
/>
133-
<CommandList
134-
className={props.variant === "popover" ? "max-h-[280px]" : undefined}
135-
>
136-
{filteredTasks.length === 0 && (
137-
<CommandEmpty>No issues found.</CommandEmpty>
138-
)}
139-
{filteredTasks.length > 0 && (
140-
<CommandGroup heading={searchQuery ? "Results" : "Recent issues"}>
141-
{filteredTasks.map((task) => {
142-
const status = task.statusId
143-
? statusMap.get(task.statusId)
144-
: undefined;
145-
return (
146-
<CommandItem
147-
key={task.id}
148-
value={task.slug}
149-
onSelect={() =>
150-
handleSelect(
151-
task.slug,
152-
task.title,
153-
task.id,
154-
task.externalUrl ?? undefined,
155-
)
156-
}
157-
className="group"
158-
>
159-
{status ? (
160-
<StatusIcon
161-
type={status.type}
162-
color={status.color}
163-
progress={status.progressPercent ?? undefined}
164-
/>
165-
) : (
166-
<span className="size-3.5 shrink-0 rounded-full border border-muted-foreground/40" />
167-
)}
168-
<span className="max-w-24 shrink-0 truncate font-mono text-xs text-muted-foreground">
169-
{task.slug}
170-
</span>
171-
<span className="min-w-0 flex-1 truncate text-xs">
172-
{task.title}
173-
</span>
174-
<span className="shrink-0 hidden text-xs text-muted-foreground group-data-[selected=true]:inline">
175-
Link ↵
176-
</span>
177-
</CommandItem>
178-
);
179-
})}
180-
</CommandGroup>
181-
)}
182-
</CommandList>
183-
</>
184-
);
185-
186-
if (props.variant === "popover") {
187-
return (
188-
<Popover open={open}>
189-
<PopoverAnchor
190-
virtualRef={props.anchorRef as React.RefObject<Element>}
191-
/>
192-
<PopoverContent
193-
className="w-80 p-0"
194-
align="start"
195-
side="bottom"
196-
onWheel={(event) => event.stopPropagation()}
197-
onPointerDownOutside={handleClose}
198-
onEscapeKeyDown={handleClose}
199-
onFocusOutside={(e) => e.preventDefault()}
200-
>
201-
<Command shouldFilter={false}>{issueListContent}</Command>
202-
</PopoverContent>
203-
</Popover>
204-
);
205-
}
206-
207122
return (
208-
<CommandDialog
123+
<Popover
209124
open={open}
210-
onOpenChange={(nextOpen) => {
211-
if (!nextOpen) setSearchQuery("");
212-
onOpenChange(nextOpen);
125+
onOpenChange={(next) => {
126+
if (!next) setSearchQuery("");
127+
setOpen(next);
213128
}}
214-
modal
215-
title="Link issue"
216-
description="Search for an issue to link"
217-
showCloseButton={false}
218129
>
219-
{issueListContent}
220-
</CommandDialog>
130+
<Tooltip>
131+
<PopoverTrigger asChild>
132+
<TooltipTrigger asChild>{children}</TooltipTrigger>
133+
</PopoverTrigger>
134+
<TooltipContent side="bottom">{tooltipLabel}</TooltipContent>
135+
</Tooltip>
136+
<PopoverContent
137+
className="w-80 p-0"
138+
align="start"
139+
side="bottom"
140+
onWheel={(event) => event.stopPropagation()}
141+
>
142+
<Command shouldFilter={false}>
143+
<CommandInput
144+
placeholder="Search issues..."
145+
value={searchQuery}
146+
onValueChange={setSearchQuery}
147+
/>
148+
<CommandList className="max-h-[280px]">
149+
{filteredTasks.length === 0 && (
150+
<CommandEmpty>No issues found.</CommandEmpty>
151+
)}
152+
{filteredTasks.length > 0 && (
153+
<CommandGroup heading={searchQuery ? "Results" : "Recent issues"}>
154+
{filteredTasks.map((task) => {
155+
const status = task.statusId
156+
? statusMap.get(task.statusId)
157+
: undefined;
158+
return (
159+
<CommandItem
160+
key={task.id}
161+
value={task.slug}
162+
onSelect={() =>
163+
handleSelect(
164+
task.slug,
165+
task.title,
166+
task.id,
167+
task.externalUrl ?? undefined,
168+
)
169+
}
170+
className="group"
171+
>
172+
{status ? (
173+
<StatusIcon
174+
type={status.type}
175+
color={status.color}
176+
progress={status.progressPercent ?? undefined}
177+
/>
178+
) : (
179+
<span className="size-3.5 shrink-0 rounded-full border border-muted-foreground/40" />
180+
)}
181+
<span className="max-w-24 shrink-0 truncate font-mono text-xs text-muted-foreground">
182+
{task.slug}
183+
</span>
184+
<span className="min-w-0 flex-1 truncate text-xs">
185+
{task.title}
186+
</span>
187+
<span className="shrink-0 hidden text-xs text-muted-foreground group-data-[selected=true]:inline">
188+
Link ↵
189+
</span>
190+
</CommandItem>
191+
);
192+
})}
193+
</CommandGroup>
194+
)}
195+
</CommandList>
196+
</Command>
197+
</PopoverContent>
198+
</Popover>
221199
);
222200
}

0 commit comments

Comments
 (0)