Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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<SpaceGroup[]>(() => {
// #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 (
<Combobox<string>
items={groups}
value={value}
onValueChange={(nextId) => {
if (nextId && nextId !== value) onChange(nextId);
}}
itemToStringLabel={(id) => byId.get(id)?.name ?? ""}
disabled={disabled}
>
<div ref={anchorRef} className="inline-flex">
<ComboboxTrigger
render={
<Button
type="button"
variant="default"
size="sm"
disabled={disabled}
aria-label="Space"
title={current?.name}
>
{triggerGlyph && (
<span className="shrink-0 text-muted-foreground">
{triggerGlyph}
</span>
)}
<span className="min-w-0 truncate">
{current?.name ?? "Space"}
</span>
</Button>
}
/>
</div>
<ComboboxContent
anchor={anchorRef}
side="bottom"
sideOffset={6}
className="min-w-[220px]"
>
<ComboboxInput placeholder="Search spaces..." showTrigger={false} />
<ComboboxEmpty>No spaces found.</ComboboxEmpty>
<ComboboxList className="max-h-[min(18rem,calc(var(--available-height,18rem)-5rem))]">
{/* `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) => (
<ComboboxGroup key={group.value} items={group.items}>
{index > 0 && <ComboboxSeparator />}
<ComboboxLabel>{group.value}</ComboboxLabel>
<ComboboxCollection>
{(id: string) => {
const space = byId.get(id);
if (!space) return null;
return (
<ComboboxItem
key={id}
value={id}
title={space.name}
className="relative"
>
{channelGlyph(space.name, { size: 14, space: true })}
{space.name}
</ComboboxItem>
);
}}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<Flex className="h-full min-w-0 flex-1">
<div className="min-w-0 flex-1">
<TaskInput
// Beside the Cloud/Local chip: which space the task files into.
// Arriving from a space's own "+" this is pre-filled; the global
// new-task entry points land on #me.
spaceSelector={({ disabled }) => (
<SpaceSelect
value={channelId}
onChange={handleSpaceChange}
disabled={disabled}
/>
)}
onTaskCreated={onTaskCreated}
channelContext={channelContext}
channelName={channelName}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand All @@ -56,6 +63,7 @@ export function AttachmentMenu({
onAttachFiles,
onInsertChip,
onRemoveChip,
onInsertSlashCommand,
iconSize = 14,
attachTooltip = "Attach",
}: AttachmentMenuProps) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -201,17 +216,17 @@ export function AttachmentMenu({
{isWindows ? (
<>
<DropdownMenuItem onClick={handleAddFile}>
<File size={14} weight="bold" />
<File size={14} />
Add file
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAddFolder}>
<FolderSimple size={14} weight="bold" />
<FolderSimple size={14} />
Add folder
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={handleAddFileOrFolder}>
<File size={14} weight="bold" />
<File size={14} />
Add file or folder
</DropdownMenuItem>
)}
Expand All @@ -220,9 +235,18 @@ export function AttachmentMenu({
onClick={handleOpenIssuePicker}
title={issueDisabledReason ?? undefined}
>
<GithubLogo size={14} weight="bold" />
<GithubLogo size={14} />
Add issue or pull request
</DropdownMenuItem>
{onInsertSlashCommand && (
<DropdownMenuItem onClick={handleInsertSlashCommand}>
{/* 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. */}
<SquareSlash size={14} strokeWidth={1.5} />
Slash commands
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<IssuePicker
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ function PromptInputHarness({
<ContextUsageIndicator usage={contextUsage} />
) : undefined
}
attachmentsPrefix={
submitAdornment={
channelContext ? (
<ChannelContextChip channelName="engineering" onRemove={() => {}} />
) : undefined
Expand Down
Loading
Loading