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
15 changes: 5 additions & 10 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,6 @@ import { GuestPendingMessages } from "./chat/GuestShareControls";
import { useDeviceState } from "~/state/device";
import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
import { ComposerContextPermissions } from "./ComposerContextPermissions";
import { resolveCompatibleRuntimeMode } from "./chat/runtimeModeConfig";
import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
import { isEditableFocused } from "../lib/editableFocus";
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
Expand Down Expand Up @@ -1838,9 +1837,6 @@ export default function ChatView(props: ChatViewProps) {
});
};
const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus);
const openComposerPermissionsStable = useStableCallback(() =>
composerRef.current?.openControl("composer.mode"),
);
const insertArtifactTemplate = (template: CodexArtifactTemplate) => {
const composer = composerRef.current;
if (!composer) return;
Expand Down Expand Up @@ -3792,13 +3788,12 @@ export default function ChatView(props: ChatViewProps) {
: {
trailing: (
<ComposerContextPermissions
runtimeMode={
resolveCompatibleRuntimeMode(
runtimeMode,
activeProviderStatus?.supportedRuntimeModes,
).mode
environmentId={environmentId}
runtimeMode={runtimeMode}
supportedRuntimeModes={
activeProviderStatus?.supportedRuntimeModes
}
onOpen={openComposerPermissionsStable}
onRuntimeModeChange={handleRuntimeModeChangeStable}
/>
),
})}
Expand Down
72 changes: 49 additions & 23 deletions apps/web/src/components/ComposerContextPermissions.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,67 @@
import type { RuntimeMode } from "@t3tools/contracts";
import type { EnvironmentId, RuntimeMode } from "@t3tools/contracts";
import { useAtomValue } from "@effect/atom-react";

import { shortcutLabelForCommand } from "../keybindings";
import { useManagedPolicy } from "../state/managedPolicy";
import { primaryServerKeybindingsAtom } from "../state/server";
import { ComposerControl } from "./chat/ComposerControl";
import { runtimeModeConfig } from "./chat/runtimeModeConfig";
import { ComposerPermissionsSection } from "./chat/ComposerFooterControls";
import { useComposerMenuProps } from "./chat/composerEventScope";
import {
resolveCompatibleRuntimeMode,
resolveRuntimeModeOptions,
runtimeModeConfig,
} from "./chat/runtimeModeConfig";
import { ComposerContextLabel } from "./ComposerContextLabel";
import { Menu, MenuPopup, MenuTrigger } from "./ui/menu";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";

/** The new-thread strip names the permissions the first send will use; clicking opens them. */
export function ComposerContextPermissions(props: {
environmentId: EnvironmentId | null;
runtimeMode: RuntimeMode;
onOpen: () => void;
supportedRuntimeModes: ReadonlyArray<RuntimeMode> | undefined;
onRuntimeModeChange: (mode: RuntimeMode) => void;
}) {
const shortcutLabel = shortcutLabelForCommand(
useAtomValue(primaryServerKeybindingsAtom),
"composer.mode",
);
const { icon: Icon, label } = runtimeModeConfig[props.runtimeMode];
const { mode, modes } = resolveCompatibleRuntimeMode(
props.runtimeMode,
props.supportedRuntimeModes,
);
const options = resolveRuntimeModeOptions(modes, useManagedPolicy(props.environmentId));
const composerMenuProps = useComposerMenuProps();
const { icon: Icon, label } = runtimeModeConfig[mode];
return (
<Tooltip>
<TooltipTrigger
render={
<ComposerControl
size="xs"
className="ms-auto min-w-0 shrink-0"
aria-label={`Permissions: ${label}`}
aria-haspopup="menu"
data-composer-context-control
onClick={props.onOpen}
/>
}
>
<Icon aria-hidden="true" className="size-3" />
<ComposerContextLabel>{label}</ComposerContextLabel>
</TooltipTrigger>
<TooltipPopup>Permissions{shortcutLabel ? ` · ${shortcutLabel}` : null}</TooltipPopup>
</Tooltip>
<Menu>
<Tooltip>
<TooltipTrigger
render={
<MenuTrigger
render={
<ComposerControl
size="xs"
className="ms-auto min-w-0 shrink-0"
aria-label={`Permissions: ${label}`}
data-composer-context-control
/>
}
/>
}
>
<Icon aria-hidden="true" className="size-3" />
<ComposerContextLabel>{label}</ComposerContextLabel>
</TooltipTrigger>
<TooltipPopup>Permissions{shortcutLabel ? ` · ${shortcutLabel}` : null}</TooltipPopup>
</Tooltip>
<MenuPopup side="top" align="end" {...composerMenuProps}>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<ComposerPermissionsSection
runtimeMode={mode}
runtimeModeOptions={options}
onRuntimeModeChange={props.onRuntimeModeChange}
/>
</MenuPopup>
</Menu>
);
}
14 changes: 5 additions & 9 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { BackgroundThreadContext } from "./backgroundThread";
import {
resolveCompatibleRuntimeMode,
runtimeModeConfig,
runtimeModeOptions as runtimeModes,
resolveRuntimeModeOptions,
} from "./runtimeModeConfig";
import { isLocalEnvironmentDisabled } from "../../localEnvironment";
import { usePrimaryEnvironmentId } from "../../state/environments";
Expand Down Expand Up @@ -285,8 +285,6 @@ function consumePasteAsTextShortcut(deadline: { current: number }): boolean {
return armed;
}

const runtimeModeOptions = runtimeModes.map((mode) => ({ mode, ...runtimeModeConfig[mode] }));

export interface ChatComposerHandle {
focusAtEnd: () => void;
focusAt: (cursor: number) => void;
Expand Down Expand Up @@ -920,12 +918,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
);
// Modes above an administrator's ceiling stay listed, disabled with the reason.
const managedPolicy = useManagedPolicy(environmentId);
const compatibleRuntimeModeOptions = runtimeModeOptions
.filter((option) => compatibleRuntimeModes.includes(option.mode))
.map((option) => {
const disabledReason = managedRuntimeModeDenial(managedPolicy, option.mode);
return disabledReason === undefined ? option : { ...option, disabledReason };
});
const compatibleRuntimeModeOptions = resolveRuntimeModeOptions(
compatibleRuntimeModes,
managedPolicy,
);
// A thread or draft already above the ceiling would be refused; offer the broadest mode left.
const runtimeModeCeilingDenial = managedRuntimeModeDenial(managedPolicy, compatibleRuntimeMode);
const runtimeModeCeilingFallback =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { page, userEvent } from "vite-plus/test/browser";

import { render } from "../../../test/render";
import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext";
import { ComposerContextPermissions } from "../ComposerContextPermissions";
import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu";
import {
focusRequestedPermission,
Expand Down Expand Up @@ -149,3 +150,38 @@ it("returns focus to the editor when picking the default hides the chip", async
await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled());
await expect.element(screen.getByRole("button", { name: "Model" })).not.toHaveFocus();
});

function ContextPermissions() {
const [mode, setMode] = useState<RuntimeMode>("full-access");
return (
<ComposerContextPermissions
environmentId={null}
runtimeMode={mode}
supportedRuntimeModes={["approval-required", "full-access"]}
onRuntimeModeChange={setMode}
/>
);
}

it("opens permissions directly from the context strip and applies a supported mode", async () => {
await using screen = await render(<ContextPermissions />, { router: false }); // no routes involved
await userEvent.click(screen.getByRole("button", { name: "Permissions: Full access" }));
await expect
.element(page.getByRole("menuitemradio", { name: "Full access" }))
.toHaveAttribute("aria-checked", "true");
await expect
.element(page.getByRole("menuitemradio", { name: "Auto", exact: true }))
.not.toBeInTheDocument();
await userEvent.click(page.getByRole("menuitemradio", { name: "Supervised" }));
const trigger = screen.getByRole("button", { name: "Permissions: Supervised" });
await expect.element(trigger).toBeVisible();
await expect
.element(page.getByRole("menuitemradio", { name: "Supervised" }))
.not.toBeInTheDocument();
await userEvent.click(trigger);
await expect
.element(page.getByRole("menuitemradio", { name: "Supervised" }))
.toHaveAttribute("aria-checked", "true");
await userEvent.keyboard("{Escape}");
await expect.element(trigger).toHaveFocus();
});
17 changes: 16 additions & 1 deletion apps/web/src/components/chat/runtimeModeConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { RuntimeMode } from "@t3tools/contracts";
import {
managedRuntimeModeDenial,
type ManagedPolicySummary,
type RuntimeMode,
} from "@t3tools/contracts";
import { type LucideIcon, LockIcon, PenLineIcon, ShieldOffIcon, SparklesIcon } from "lucide-react";

export const runtimeModeConfig: Record<
Expand Down Expand Up @@ -41,3 +45,14 @@ export function resolveCompatibleRuntimeMode(
: runtimeModeOptions;
return { modes, mode: modes.includes(runtimeMode) ? runtimeMode : (modes[0] ?? runtimeMode) };
}

export function resolveRuntimeModeOptions(
modes: ReadonlyArray<RuntimeMode>,
managedPolicy: ManagedPolicySummary | undefined,
) {
return modes.map((mode) => {
const option = { mode, ...runtimeModeConfig[mode] };
const disabledReason = managedRuntimeModeDenial(managedPolicy, mode);
return disabledReason === undefined ? option : { ...option, disabledReason };
});
}
Loading