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
5 changes: 4 additions & 1 deletion apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,10 @@ function useEnvironmentOptions(): ReadonlyArray<EnvironmentOption> {
supportsCloneTracking: config?.environment.capabilities.projectCloneTracking === true,
};
});
return Arr.sort(options, environmentOptionOrder);
return Arr.sort(
options.filter((environment) => canCreateProjectInEnvironment(environment.connectionState)),
environmentOptionOrder,
);
}, [connectedEnvironments, savedConnectionsById, serverConfigByEnvironmentId]);
}

Expand Down
40 changes: 26 additions & 14 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1014,21 +1014,23 @@ function OpenCommandPaletteDialog(props: {
);

const addProjectEnvironmentOptions = useMemo(() => {
const options = environments.map((environment): AddProjectEnvironmentOption => {
const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
return {
environmentId: environment.environmentId,
label: resolveEnvironmentOptionLabel({
isPrimary,
const options = environments
.filter((environment) => canCreateProjectInEnvironment(environment.connection.phase))
.map((environment): AddProjectEnvironmentOption => {
const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
return {
environmentId: environment.environmentId,
runtimeLabel: environment.label,
}),
isPrimary,
machine: resolveEnvironmentMachineKind(environment.serverConfig),
isConnected: canCreateProjectInEnvironment(environment.connection.phase),
status: connectionStatusText(environment.connection),
};
});
label: resolveEnvironmentOptionLabel({
isPrimary,
environmentId: environment.environmentId,
runtimeLabel: environment.label,
}),
isPrimary,
machine: resolveEnvironmentMachineKind(environment.serverConfig),
isConnected: canCreateProjectInEnvironment(environment.connection.phase),
status: connectionStatusText(environment.connection),
};
});

options.sort((left, right) => {
if (left.isPrimary !== right.isPrimary) {
Expand Down Expand Up @@ -1705,6 +1707,14 @@ function OpenCommandPaletteDialog(props: {
);

const openAddProjectFlow = useCallback(() => {
// With no connected environment there is nothing to browse, so the only
// useful next step is connecting one.
if (addProjectEnvironmentOptions.length === 0) {
setOpen(false);
void navigate({ to: "/settings/connections" });
return;
}

if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) {
pushPaletteView({
addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
Expand All @@ -1730,7 +1740,9 @@ function OpenCommandPaletteDialog(props: {
addProjectEnvironmentGroups,
addProjectEnvironmentOptions.length,
defaultAddProjectEnvironmentId,
navigate,
pushPaletteView,
setOpen,
startAddProjectSourceSelection,
]);

Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,11 @@ interface ComposerPromptEditorProps {
allowUnicodeSkillAliases?: boolean;
disabled: boolean;
placeholder: string;
ariaLabel?: string | undefined;
/** Identifies an editor with suggestions, even while its list is closed. */
suggestionListId?: string | undefined;
/** References the highlighted option only while its list is rendered. */
activeSuggestionId?: string | undefined;
containerClassName?: string;
className?: string;
placeholderClassName?: string;
Expand Down Expand Up @@ -1685,6 +1690,9 @@ function ComposerPromptEditorInner({
allowUnicodeSkillAliases = false,
disabled,
placeholder,
ariaLabel,
suggestionListId,
activeSuggestionId,
containerClassName,
className,
placeholderClassName,
Expand Down Expand Up @@ -2080,6 +2088,23 @@ function ComposerPromptEditorInner({
className,
)}
data-testid="composer-editor"
ariaMultiline
ariaLabel={ariaLabel}
// Lexical drops the autocomplete, list and active-option
// references (and marks the textbox read-only) while the editor
// is not editable.
{...(suggestionListId
? {
ariaAutoComplete: "list" as const,
"aria-haspopup": disabled ? undefined : ("listbox" as const),
...(activeSuggestionId
? {
ariaControls: suggestionListId,
ariaActiveDescendant: activeSuggestionId,
}
: {}),
}
: {})}
aria-placeholder={placeholder}
placeholder={<span />}
onKeyDown={(event) => {
Expand Down Expand Up @@ -2175,6 +2200,9 @@ export function ComposerPromptEditor({
allowUnicodeSkillAliases = false,
disabled,
placeholder,
ariaLabel,
suggestionListId,
activeSuggestionId,
containerClassName,
className,
placeholderClassName,
Expand Down Expand Up @@ -2232,6 +2260,9 @@ export function ComposerPromptEditor({
allowUnicodeSkillAliases={allowUnicodeSkillAliases}
disabled={disabled}
placeholder={placeholder}
ariaLabel={ariaLabel}
suggestionListId={suggestionListId}
activeSuggestionId={activeSuggestionId}
{...(containerClassName ? { containerClassName } : {})}
onChange={onChange}
{...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})}
Expand Down
36 changes: 33 additions & 3 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import {
type ReactNode,
useCallback,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useMemo,
Expand Down Expand Up @@ -295,7 +296,11 @@ import { useDebouncedValue } from "~/state/queries";

import { ProviderModelPicker } from "./ProviderModelPicker";
import { hasProviderSetup } from "./ProviderStatusBanner";
import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
import {
type ComposerCommandItem,
ComposerCommandMenu,
composerSuggestionOptionId,
} from "./ComposerCommandMenu";
import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
import { ComposerImageThumbnail } from "./ComposerImageThumbnail";
Expand Down Expand Up @@ -2965,6 +2970,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
resetTrigger: resetComposerTrigger,
} = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length));
const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
const composerSuggestionId = useId();
const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`;
// Active ArrowUp recall. Cleared on edit and on thread switch.
const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null);
const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
Expand Down Expand Up @@ -3363,7 +3370,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)

const composerMenuOpen = Boolean(composerTrigger);
const composerMenuSearchKey = composerTrigger
? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
? `${composerSuggestionListId}:${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
: null;
const activeComposerMenuItem = useMemo(() => {
const activeItemId = resolveComposerMenuActiveItemId({
Expand All @@ -3390,6 +3397,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
);

const isComposerApprovalState = activePendingApproval !== null;
const composerSuggestionsVisible = composerMenuOpen && !isComposerApprovalState;
const composerSuggestionListVisible = composerSuggestionsVisible && composerMenuItems.length > 0;
const isChoiceOnlyPendingQuestion =
activePendingProgress?.activeQuestion?.allowCustomAnswer === false;
const activePendingUserInput = pendingUserInputs[0] ?? null;
Expand Down Expand Up @@ -4135,6 +4144,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// ------------------------------------------------------------------
useEffect(() => {
setComposerHighlightedItemId(null);
setComposerHighlightedSearchKey(null);
setComposerSubmissionError(null);
setProviderInputSubmissionError(null);
setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
Expand Down Expand Up @@ -7630,9 +7640,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
</ComposerCommandMenuLayer>
)}

{composerMenuOpen && !isComposerApprovalState && (
<div role="status" aria-atomic="true" className="sr-only">
{composerSuggestionsVisible && composerMenuItems.length === 0
? isComposerMenuLoading
? composerTriggerKind === "pull-request"
? "Finding pull request..."
: "Searching workspace files..."
: composerMenuEmptyState
: ""}
</div>
{composerSuggestionsVisible && (
<ComposerCommandMenuLayer anchor={composerMenuAnchor}>
<ComposerCommandMenu
listId={composerSuggestionListId}
items={composerMenuItems}
resolvedTheme={resolvedTheme}
isLoading={isComposerMenuLoading}
Expand Down Expand Up @@ -8045,6 +8065,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
<ComposerContextScope value={true}>
<ComposerContextActionsContext value={composerContextActions}>
<ComposerPromptEditor
ariaLabel="Message"
suggestionListId={composerSuggestionListId}
activeSuggestionId={
composerSuggestionListVisible && activeComposerMenuItem
? composerSuggestionOptionId(
composerSuggestionListId,
activeComposerMenuItem.id,
)
: undefined
}
editorRef={composerEditorRef}
value={
isComposerApprovalState
Expand Down
74 changes: 73 additions & 1 deletion apps/web/src/components/chat/ComposerCommandMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,37 @@ import { renderToStaticMarkup } from "react-dom/server";
import { ProviderDriverKind } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { ComposerCommandMenu } from "./ComposerCommandMenu";
import { ComposerCommandMenu, composerSuggestionOptionId } from "./ComposerCommandMenu";

describe("composerSuggestionOptionId", () => {
it("keeps whitespace, escape-like paths, and malformed UTF-16 distinct", () => {
const paths = [
"docs/my file.md",
"docs/my_file.md",
"docs/my%20file.md",
"docs/my\tfile.md",
"docs/\ud800.md",
"docs/\ud801.md",
"docs/\udc00.md",
"docs/\ufffd.md",
"docs/\\ud800.md",
"docs/\ud83d\ude80.md",
];
const ids = paths.map((path) => composerSuggestionOptionId("suggestions", `path:file:${path}`));

expect(new Set(ids).size).toBe(paths.length);
for (const id of ids) expect(id).not.toMatch(/\s|[\ud800-\udfff]/u);
expect(composerSuggestionOptionId("other-composer", paths[0]!)).not.toBe(
composerSuggestionOptionId("suggestions", paths[0]!),
);
});
});

describe("ComposerCommandMenu", () => {
it("renders slash-command results as an attached composer drawer", () => {
const markup = renderToStaticMarkup(
<ComposerCommandMenu
listId="test-suggestions"
items={[]}
resolvedTheme="dark"
isLoading={false}
Expand All @@ -25,6 +50,7 @@ describe("ComposerCommandMenu", () => {
it("renders commands without a category heading or invented icons", () => {
const markup = renderToStaticMarkup(
<ComposerCommandMenu
listId="test-suggestions"
items={[
{
id: "slash:model",
Expand Down Expand Up @@ -54,9 +80,54 @@ describe("ComposerCommandMenu", () => {
expect(markup).toContain("text-left");
});

it("names the listbox and exposes the active option to the editor", () => {
const markup = renderToStaticMarkup(
<ComposerCommandMenu
listId="test-suggestions"
items={[
{
id: "slash:model",
type: "slash-command",
command: "model",
label: "/model",
description: "Switch response model for this thread",
},
{
id: "slash:plan",
type: "slash-command",
command: "plan",
label: "/plan",
description: "Switch this thread into plan mode",
},
]}
resolvedTheme="dark"
isLoading={false}
triggerKind="slash-command"
activeItemId="slash:plan"
onHighlightedItemChange={() => {}}
onSelect={() => {}}
/>,
);
const openingTag = (id: string) =>
markup.match(
new RegExp(`<[^>]*\\sid="${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>`),
)?.[0] ?? "";

const listbox = openingTag("test-suggestions");
expect(listbox).toContain('role="listbox"');
expect(listbox).toContain('aria-label="Commands"');
const active = openingTag(composerSuggestionOptionId("test-suggestions", "slash:plan"));
expect(active).toContain('role="option"');
expect(active).toContain('aria-selected="true"');
const inactive = openingTag(composerSuggestionOptionId("test-suggestions", "slash:model"));
expect(inactive).toContain('role="option"');
expect(inactive).toContain('aria-selected="false"');
});

it("renders the skill source icon inside its badge", () => {
const markup = renderToStaticMarkup(
<ComposerCommandMenu
listId="test-suggestions"
items={[
{
id: "skill:codex:browser",
Expand Down Expand Up @@ -99,6 +170,7 @@ describe("ComposerCommandMenu", () => {
it("keeps slash skills aligned with the source icon inside the badge", () => {
const markup = renderToStaticMarkup(
<ComposerCommandMenu
listId="test-suggestions"
items={[
{
id: "skill:codex:ask-matt",
Expand Down
Loading
Loading