diff --git a/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index c70143945075..d92522f564e6 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -452,7 +452,6 @@ export const ChannelHomeComposer = forwardRef< [sessionId, modeOption, setConfigOption], ); - const hints = ["@ to add files", "/ for skills"].join(", "); const isBusy = isCreatingTask || isStartingCanvas; const submitComposer = canvasArmed ? handleCanvasSubmit : submit; @@ -484,7 +483,7 @@ export const ChannelHomeComposer = forwardRef< placeholder={ canvasArmed ? "Describe the canvas to build — the agent generates and publishes it" - : `What do you want to ship? ${hints}` + : `What do you want to ship?` } editorHeight="large" disabled={isBusy} diff --git a/products/desktop/packages/ui/src/features/canvas/components/SpaceSelect.tsx b/products/desktop/packages/ui/src/features/canvas/components/SpaceSelect.tsx new file mode 100644 index 000000000000..1a64b1f0a8da --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/components/SpaceSelect.tsx @@ -0,0 +1,154 @@ +import { + Button, + Combobox, + ComboboxCollection, + ComboboxContent, + ComboboxEmpty, + ComboboxGroup, + ComboboxInput, + ComboboxItem, + ComboboxLabel, + ComboboxList, + ComboboxSeparator, + ComboboxTrigger, +} from "@posthog/quill"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useMemo, useRef } from "react"; + +interface SpaceGroup { + value: string; + items: string[]; +} + +/** + * Which space a new task files into — a chip for the composer's selector row, + * drawn like the EnvironmentSelector and WorkspaceModeSelect beside it. A + * project can carry hundreds of spaces, so the list is searchable: starred + * spaces (with #me leading) sit above the rest, under the same "Starred" / + * "Spaces" headings the sidebar list uses. + */ +export function SpaceSelect({ + value, + onChange, + disabled = false, +}: { + value: string; + onChange: (channelId: string) => void; + /** + * Held shut while a task is being created. Retargeting mid-submit navigates + * away from the composer that owns the in-flight request, so the task lands + * in the space you left rather than the one you picked. + */ + disabled?: boolean; +}) { + const { channels } = useChannels(); + const anchorRef = useRef(null); + const current = channels.find((c) => c.id === value) ?? null; + + const byId = useMemo( + () => new Map(channels.map((c) => [c.id, c])), + [channels], + ); + + // Ids, not Channel objects: the channels query repolls and rebuilds its + // objects, so a selected object stops matching the list by identity and the + // combobox silently drops the selection. Ids compare by value. + // + // `useChannels` already sorts by name, so both groups stay alphabetical + // without re-sorting; #me leads because it's where an unfiled task goes. + // An empty group is dropped rather than rendered as a bare heading. + const groups = useMemo(() => { + // #me is hoisted rather than left to the name sort, which would drop it + // below any starred space alphabetically ahead of it. + const personal = channels.filter((c) => c.channelType === "personal"); + const starred = [ + ...personal, + ...channels.filter((c) => c.channelType !== "personal" && c.starred), + ]; + const rest = channels.filter( + (c) => c.channelType !== "personal" && !c.starred, + ); + return [ + { value: "Starred", items: starred.map((c) => c.id) }, + { value: "Spaces", items: rest.map((c) => c.id) }, + ].filter((group) => group.items.length > 0); + }, [channels]); + + const triggerGlyph = channelGlyph(current?.name, { size: 14, space: true }); + + return ( + + items={groups} + value={value} + onValueChange={(nextId) => { + if (nextId && nextId !== value) onChange(nextId); + }} + itemToStringLabel={(id) => byId.get(id)?.name ?? ""} + disabled={disabled} + > +
+ + {triggerGlyph && ( + + {triggerGlyph} + + )} + + {current?.name ?? "Space"} + + + } + /> +
+ + + No spaces found. + + {/* `index` counts the groups Base UI actually renders, which drops + any whose items all filter out, so the rule leads each group + after the first rather than trailing every group but the last — + the trailing form strands a separator when the tail group is + filtered away. */} + {(group: SpaceGroup, index: number) => ( + + {index > 0 && } + {group.value} + + {(id: string) => { + const space = byId.get(id); + if (!space) return null; + return ( + + {channelGlyph(space.name, { size: 14, space: true })} + {space.name} + + ); + }} + + + )} + + + + ); +} diff --git a/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index bc2b4b4da90c..6b28e70cc1e2 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -3,6 +3,7 @@ import type { Task } from "@posthog/shared/domain-types"; import { CHANNEL_TASK_SUGGESTIONS } from "@posthog/ui/features/canvas/channelTaskSuggestions"; import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; import { ChannelContextPanel } from "@posthog/ui/features/canvas/components/ChannelContextPanel"; +import { SpaceSelect } from "@posthog/ui/features/canvas/components/SpaceSelect"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; @@ -106,10 +107,38 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { [channelId, fileTask, navigate, queryClient], ); + // Retargeting navigates to that space's own new-task route; the composer's + // draft lives in the shared "task-input" draft store, so text typed before + // switching survives the navigation. + const handleSpaceChange = useCallback( + (nextChannelId: string) => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_open", + surface: "new_task", + channel_id: nextChannelId, + }); + void navigate({ + to: "/website/$channelId/new", + params: { channelId: nextChannelId }, + }); + }, + [navigate], + ); + return (
( + + )} onTaskCreated={onTaskCreated} channelContext={channelContext} channelName={channelName} diff --git a/products/desktop/packages/ui/src/features/message-editor/components/AttachmentMenu.tsx b/products/desktop/packages/ui/src/features/message-editor/components/AttachmentMenu.tsx index d7d291a0d8d5..26773dd6bebb 100644 --- a/products/desktop/packages/ui/src/features/message-editor/components/AttachmentMenu.tsx +++ b/products/desktop/packages/ui/src/features/message-editor/components/AttachmentMenu.tsx @@ -15,6 +15,7 @@ import { isRasterImageFile } from "@posthog/shared"; import { useAddDirectoryDialogStore } from "@posthog/ui/features/folder-picker/addDirectoryDialogStore"; import { toast } from "@posthog/ui/primitives/toast"; import { useQuery } from "@tanstack/react-query"; +import { SquareSlash } from "lucide-react"; import { useRef, useState } from "react"; import { getGhStatus, selectAttachments } from "../hostApi"; import { @@ -32,6 +33,12 @@ interface AttachmentMenuProps { onAttachFiles?: (files: File[]) => void; onInsertChip: (chip: MentionChip) => void; onRemoveChip?: (chipId: string) => void; + /** + * Writes a slash at the start of the composer, opening the command list the + * same way typing one does. Omitted where the menu has no editor to write + * into, which hides the item. + */ + onInsertSlashCommand?: () => void; iconSize?: number; attachTooltip?: string; } @@ -56,6 +63,7 @@ export function AttachmentMenu({ onAttachFiles, onInsertChip, onRemoveChip, + onInsertSlashCommand, iconSize = 14, attachTooltip = "Attach", }: AttachmentMenuProps) { @@ -162,6 +170,13 @@ export function AttachmentMenu({ setIssuePickerOpen(true); }; + // Close first: the command list opens against the composer, and leaving this + // menu up would stack one popup over the other. + const handleInsertSlashCommand = () => { + setMenuOpen(false); + onInsertSlashCommand?.(); + }; + const handleIssueSelect = (chip: MentionChip) => { onInsertChip(chip); setIssuePickerOpen(false); @@ -201,17 +216,17 @@ export function AttachmentMenu({ {isWindows ? ( <> - + Add file - + Add folder ) : ( - + Add file or folder )} @@ -220,9 +235,18 @@ export function AttachmentMenu({ onClick={handleOpenIssuePicker} title={issueDisabledReason ?? undefined} > - + Add issue or pull request + {onInsertSlashCommand && ( + + {/* Lucide's default stroke is heavier than Phosphor's regular + weight at the same size: 2/24 of the viewBox against 16/256. + 1.5 lands on the same rendered thickness as the icons above. */} + + Slash commands + + )} ) : undefined } - attachmentsPrefix={ + submitAdornment={ channelContext ? ( {}} /> ) : undefined diff --git a/products/desktop/packages/ui/src/features/message-editor/components/PromptInput.tsx b/products/desktop/packages/ui/src/features/message-editor/components/PromptInput.tsx index f8511405c50f..4067c76db2d7 100644 --- a/products/desktop/packages/ui/src/features/message-editor/components/PromptInput.tsx +++ b/products/desktop/packages/ui/src/features/message-editor/components/PromptInput.tsx @@ -16,7 +16,13 @@ import { hasOpenOverlay } from "@posthog/ui/utils/overlay"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { EditorContent } from "@tiptap/react"; import clsx from "clsx"; -import { forwardRef, useCallback, useEffect, useImperativeHandle } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useState, +} from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useSkills } from "../../skills/useSkills"; import { skillToEditorCommand } from "../commands"; @@ -72,11 +78,14 @@ export interface PromptInputProps { messagingModeToggle?: React.ReactNode; historyButton?: React.ReactNode; /** - * Rendered in the attachments row at the top of the composer, ahead of the - * attachments themselves — for context the prompt carries that the user did - * not attach by hand (e.g. a channel's CONTEXT.md). + * Pinned inside the composer box beside the send button — for context the + * prompt always carries that the user did not attach by hand (e.g. a + * channel's CONTEXT.md). It sits apart from the attachments row on purpose: + * hand-picked files come and go, this rides along with every send. The + * editor reserves its measured width, so keep it a fixed size rather than + * one that changes on hover. */ - attachmentsPrefix?: React.ReactNode; + submitAdornment?: React.ReactNode; /** * Pushed to the far end of the composer's toolbar row — for read-only status * about the session the prompt goes to (e.g. context usage), as opposed to @@ -149,7 +158,7 @@ export const PromptInput = forwardRef( reasoningSelector, messagingModeToggle, historyButton, - attachmentsPrefix, + submitAdornment, toolbarEndSlot, headerAddon, hideDefaultToolbar = false, @@ -180,6 +189,24 @@ export const PromptInput = forwardRef( const clearFocusRequest = useDraftStore((s) => s.actions.clearFocusRequest); const slotMachineMode = useSettingsStore((s) => s.slotMachineMode); const { data: skills } = useSkills(); + // Seeded at the send button's own width so the first paint already clears + // it, rather than laying the text out full-width and reflowing it. + const [submitClusterWidth, setSubmitClusterWidth] = useState(40); + // The text's right inset has to clear whatever sits over its bottom-right + // corner. That used to be the send button alone (a fixed 40px), but an + // adornment beside it makes the width depend on its content, so measure. + // A callback ref rather than an effect: the cluster mounts and unmounts + // with the button, and this re-observes the new node each time. + const submitClusterRef = useCallback((el: HTMLSpanElement | null) => { + if (!el) return; + const observer = new ResizeObserver(([entry]) => { + // The cluster is inset by 4px (right-1); the same again keeps the text + // from running up against it. + setSubmitClusterWidth(entry.contentRect.width + 8); + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); const { editor, @@ -195,6 +222,7 @@ export const PromptInput = forwardRef( setContent, insertEditorContent, insertChip, + insertSlashCommand, removeChipById, replaceChipAttrs, attachments, @@ -437,6 +465,11 @@ export const PromptInput = forwardRef( onAttachFiles={onAttachFiles} onInsertChip={insertChip} onRemoveChip={removeChipById} + // No `/` extension registered means the item would type a bare + // slash and open nothing, so it hides instead. + onInsertSlashCommand={ + enableCommands ? insertSlashCommand : undefined + } /> {onModeChange && ( ( canvas={canvas} /> )} - {modelSelector && {modelSelector}} - {reasoningSelector && {reasoningSelector}} + {/* Direct flex children, not wrapped in a span: an inline wrapper + builds a line box whose leading pushes the trigger a pixel below + the toolbar's other buttons. */} + {modelSelector} + {reasoningSelector} {isBashMode && ( ! bash @@ -479,12 +515,11 @@ export const PromptInput = forwardRef( {headerAddon && ( {headerAddon} )} - {(attachmentsPrefix || attachments.length > 0) && ( + {attachments.length > 0 && ( {/* One provider for the row: moving between squares reuses the open delay instead of re-waiting it per attachment. */} - {attachmentsPrefix} ( column beside it. Laid out beside the text, it would push the scroll container inwards and strand the scrollbar mid-box; over it, the container runs to the edge and the bar hugs it. The text - reserves the button's width so a long line never runs underneath. */} + reserves the cluster's width so a long line never runs underneath + — measured rather than fixed, because an adornment beside the + button makes that width depend on what's in it. */}
( >
- {submitButton && ( - {submitButton} + {(submitButton || submitAdornment) && ( + + {submitAdornment} + {submitButton} + )}
diff --git a/products/desktop/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx b/products/desktop/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx index 5b0e54226455..ccc3d6b508ee 100644 --- a/products/desktop/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx +++ b/products/desktop/packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx @@ -10,7 +10,7 @@ import { WarningIcon, XIcon, } from "@phosphor-icons/react"; -import { Chip } from "@posthog/quill"; +import { Chip, cn } from "@posthog/quill"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { usePasteUndoStore } from "../pasteUndoStore"; @@ -88,6 +88,28 @@ function DefaultChip({ const isGithubRef = type === "github_issue" || type === "github_pr"; const canOpenUrl = isGithubRef && /^https:\/\//.test(id); + // A skill reads as part of the sentence being written, not as an object + // attached to it, so it stays plain text rather than taking a chip's border + // and remove button. Selecting it — arrowing onto it, or the backspace that + // is about to delete it — turns the run destructive, which is the only + // warning left once there is no × to aim at. + if (isCommand) { + return ( + + {prefix} + {label} + + ); + } + const chipContent = ( )} - + {item.label} {item.description && ( @@ -113,30 +117,31 @@ export const SuggestionList = forwardRef<
!hasMouseMoved && setHasMouseMoved(true)} > - {items.map((item, index) => { - const isSelected = index === selectedIndex; - return ( - - ); - })} + {items.map((item, index) => ( + { + itemRefs.current[index] = el; + }} + size="xs" + // `option` inside the listbox above, not the `menuitem` this + // primitive defaults to. Selection is the plugin's, driven by the + // keys it forwards, so it rides on aria-selected rather than the + // focus these rows never take (the caret stays in the editor). + role="option" + aria-selected={index === selectedIndex} + onClick={() => command(item)} + onMouseEnter={() => hasMouseMoved && setSelectedIndex(index)} + className="w-full text-left aria-selected:bg-fill-selected" + > + {renderItem ? renderItem(item) : } + + ))}
-
+
navigate diff --git a/products/desktop/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts b/products/desktop/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts index 088e5ec65687..914160e7ea0d 100644 --- a/products/desktop/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts +++ b/products/desktop/packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts @@ -902,6 +902,16 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { [editor, draft, attachments], ); + // Position 1 is the start of the first paragraph. Writing the slash there + // and dropping the caret after it leaves the editor in exactly the state + // typing "/" would, which is what the suggestion plugin watches for, so the + // command list opens on its own rather than needing to be opened here. + const insertSlashCommand = useCallback(() => { + if (!editor) return; + editor.chain().insertContentAt(1, "/").focus(2).run(); + draft.saveDraft(editor, attachments); + }, [editor, draft, attachments]); + const removeChipById = useCallback( (chipId: string) => { if (!editor) return; @@ -962,6 +972,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { setContent, insertEditorContent, insertChip, + insertSlashCommand, removeChipById, replaceChipAttrs, attachments, diff --git a/products/desktop/packages/ui/src/features/task-detail/components/ChannelContextChip.tsx b/products/desktop/packages/ui/src/features/task-detail/components/ChannelContextChip.tsx index 3c37b3ae2e0c..9a80b3801586 100644 --- a/products/desktop/packages/ui/src/features/task-detail/components/ChannelContextChip.tsx +++ b/products/desktop/packages/ui/src/features/task-detail/components/ChannelContextChip.tsx @@ -1,10 +1,18 @@ -import { Attachment } from "@posthog/ui/features/message-editor/components/Attachment"; +import { + Chip, + ChipClose, + cn, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@posthog/quill"; /** - * The channel CONTEXT.md riding along with the prompt, shown in the composer's - * attachments row. Deliberately the same component the user's own attachments - * use, because that is what it is from the writer's point of view: something - * extra travelling with the message, which they can inspect or drop. + * The channel CONTEXT.md riding along with the prompt, pinned inside the + * composer beside the send button rather than sitting in the attachments row. + * It isn't an attachment the writer chose: it comes with the space and travels + * with every prompt sent from it, so it keeps its own spot and shows its name + * instead of collapsing to an extension square like a real attachment does. */ export function ChannelContextChip({ channelName, @@ -15,12 +23,42 @@ export function ChannelContextChip({ onView?: () => void; onRemove: () => void; }) { + const chip = ( + so ChipClose can nest legally, but the button + // behaviour underneath assumes a real } />