Skip to content

Commit 3d369d5

Browse files
authored
feat(web): open full access menu from the new thread context strip (#30275)
1 parent 3ee5515 commit 3d369d5

5 files changed

Lines changed: 111 additions & 43 deletions

File tree

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,6 @@ import { GuestPendingMessages } from "./chat/GuestShareControls";
177177
import { useDeviceState } from "~/state/device";
178178
import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
179179
import { ComposerContextPermissions } from "./ComposerContextPermissions";
180-
import { resolveCompatibleRuntimeMode } from "./chat/runtimeModeConfig";
181180
import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop";
182181
import { isEditableFocused } from "../lib/editableFocus";
183182
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
@@ -1838,9 +1837,6 @@ export default function ChatView(props: ChatViewProps) {
18381837
});
18391838
};
18401839
const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus);
1841-
const openComposerPermissionsStable = useStableCallback(() =>
1842-
composerRef.current?.openControl("composer.mode"),
1843-
);
18441840
const insertArtifactTemplate = (template: CodexArtifactTemplate) => {
18451841
const composer = composerRef.current;
18461842
if (!composer) return;
@@ -3792,13 +3788,12 @@ export default function ChatView(props: ChatViewProps) {
37923788
: {
37933789
trailing: (
37943790
<ComposerContextPermissions
3795-
runtimeMode={
3796-
resolveCompatibleRuntimeMode(
3797-
runtimeMode,
3798-
activeProviderStatus?.supportedRuntimeModes,
3799-
).mode
3791+
environmentId={environmentId}
3792+
runtimeMode={runtimeMode}
3793+
supportedRuntimeModes={
3794+
activeProviderStatus?.supportedRuntimeModes
38003795
}
3801-
onOpen={openComposerPermissionsStable}
3796+
onRuntimeModeChange={handleRuntimeModeChangeStable}
38023797
/>
38033798
),
38043799
})}
Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,67 @@
1-
import type { RuntimeMode } from "@t3tools/contracts";
1+
import type { EnvironmentId, RuntimeMode } from "@t3tools/contracts";
22
import { useAtomValue } from "@effect/atom-react";
33

44
import { shortcutLabelForCommand } from "../keybindings";
5+
import { useManagedPolicy } from "../state/managedPolicy";
56
import { primaryServerKeybindingsAtom } from "../state/server";
67
import { ComposerControl } from "./chat/ComposerControl";
7-
import { runtimeModeConfig } from "./chat/runtimeModeConfig";
8+
import { ComposerPermissionsSection } from "./chat/ComposerFooterControls";
9+
import { useComposerMenuProps } from "./chat/composerEventScope";
10+
import {
11+
resolveCompatibleRuntimeMode,
12+
resolveRuntimeModeOptions,
13+
runtimeModeConfig,
14+
} from "./chat/runtimeModeConfig";
815
import { ComposerContextLabel } from "./ComposerContextLabel";
16+
import { Menu, MenuPopup, MenuTrigger } from "./ui/menu";
917
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
1018

11-
/** The new-thread strip names the permissions the first send will use; clicking opens them. */
1219
export function ComposerContextPermissions(props: {
20+
environmentId: EnvironmentId | null;
1321
runtimeMode: RuntimeMode;
14-
onOpen: () => void;
22+
supportedRuntimeModes: ReadonlyArray<RuntimeMode> | undefined;
23+
onRuntimeModeChange: (mode: RuntimeMode) => void;
1524
}) {
1625
const shortcutLabel = shortcutLabelForCommand(
1726
useAtomValue(primaryServerKeybindingsAtom),
1827
"composer.mode",
1928
);
20-
const { icon: Icon, label } = runtimeModeConfig[props.runtimeMode];
29+
const { mode, modes } = resolveCompatibleRuntimeMode(
30+
props.runtimeMode,
31+
props.supportedRuntimeModes,
32+
);
33+
const options = resolveRuntimeModeOptions(modes, useManagedPolicy(props.environmentId));
34+
const composerMenuProps = useComposerMenuProps();
35+
const { icon: Icon, label } = runtimeModeConfig[mode];
2136
return (
22-
<Tooltip>
23-
<TooltipTrigger
24-
render={
25-
<ComposerControl
26-
size="xs"
27-
className="ms-auto min-w-0 shrink-0"
28-
aria-label={`Permissions: ${label}`}
29-
aria-haspopup="menu"
30-
data-composer-context-control
31-
onClick={props.onOpen}
32-
/>
33-
}
34-
>
35-
<Icon aria-hidden="true" className="size-3" />
36-
<ComposerContextLabel>{label}</ComposerContextLabel>
37-
</TooltipTrigger>
38-
<TooltipPopup>Permissions{shortcutLabel ? ` · ${shortcutLabel}` : null}</TooltipPopup>
39-
</Tooltip>
37+
<Menu>
38+
<Tooltip>
39+
<TooltipTrigger
40+
render={
41+
<MenuTrigger
42+
render={
43+
<ComposerControl
44+
size="xs"
45+
className="ms-auto min-w-0 shrink-0"
46+
aria-label={`Permissions: ${label}`}
47+
data-composer-context-control
48+
/>
49+
}
50+
/>
51+
}
52+
>
53+
<Icon aria-hidden="true" className="size-3" />
54+
<ComposerContextLabel>{label}</ComposerContextLabel>
55+
</TooltipTrigger>
56+
<TooltipPopup>Permissions{shortcutLabel ? ` · ${shortcutLabel}` : null}</TooltipPopup>
57+
</Tooltip>
58+
<MenuPopup side="top" align="end" {...composerMenuProps}>
59+
<ComposerPermissionsSection
60+
runtimeMode={mode}
61+
runtimeModeOptions={options}
62+
onRuntimeModeChange={props.onRuntimeModeChange}
63+
/>
64+
</MenuPopup>
65+
</Menu>
4066
);
4167
}

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { BackgroundThreadContext } from "./backgroundThread";
55
import {
66
resolveCompatibleRuntimeMode,
77
runtimeModeConfig,
8-
runtimeModeOptions as runtimeModes,
8+
resolveRuntimeModeOptions,
99
} from "./runtimeModeConfig";
1010
import { isLocalEnvironmentDisabled } from "../../localEnvironment";
1111
import { usePrimaryEnvironmentId } from "../../state/environments";
@@ -285,8 +285,6 @@ function consumePasteAsTextShortcut(deadline: { current: number }): boolean {
285285
return armed;
286286
}
287287

288-
const runtimeModeOptions = runtimeModes.map((mode) => ({ mode, ...runtimeModeConfig[mode] }));
289-
290288
export interface ChatComposerHandle {
291289
focusAtEnd: () => void;
292290
focusAt: (cursor: number) => void;
@@ -920,12 +918,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
920918
);
921919
// Modes above an administrator's ceiling stay listed, disabled with the reason.
922920
const managedPolicy = useManagedPolicy(environmentId);
923-
const compatibleRuntimeModeOptions = runtimeModeOptions
924-
.filter((option) => compatibleRuntimeModes.includes(option.mode))
925-
.map((option) => {
926-
const disabledReason = managedRuntimeModeDenial(managedPolicy, option.mode);
927-
return disabledReason === undefined ? option : { ...option, disabledReason };
928-
});
921+
const compatibleRuntimeModeOptions = resolveRuntimeModeOptions(
922+
compatibleRuntimeModes,
923+
managedPolicy,
924+
);
929925
// A thread or draft already above the ceiling would be refused; offer the broadest mode left.
930926
const runtimeModeCeilingDenial = managedRuntimeModeDenial(managedPolicy, compatibleRuntimeMode);
931927
const runtimeModeCeilingFallback =

‎apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { page, userEvent } from "vite-plus/test/browser";
55

66
import { render } from "../../../test/render";
77
import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext";
8+
import { ComposerContextPermissions } from "../ComposerContextPermissions";
89
import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu";
910
import {
1011
focusRequestedPermission,
@@ -149,3 +150,38 @@ it("returns focus to the editor when picking the default hides the chip", async
149150
await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled());
150151
await expect.element(screen.getByRole("button", { name: "Model" })).not.toHaveFocus();
151152
});
153+
154+
function ContextPermissions() {
155+
const [mode, setMode] = useState<RuntimeMode>("full-access");
156+
return (
157+
<ComposerContextPermissions
158+
environmentId={null}
159+
runtimeMode={mode}
160+
supportedRuntimeModes={["approval-required", "full-access"]}
161+
onRuntimeModeChange={setMode}
162+
/>
163+
);
164+
}
165+
166+
it("opens permissions directly from the context strip and applies a supported mode", async () => {
167+
await using screen = await render(<ContextPermissions />, { router: false }); // no routes involved
168+
await userEvent.click(screen.getByRole("button", { name: "Permissions: Full access" }));
169+
await expect
170+
.element(page.getByRole("menuitemradio", { name: "Full access" }))
171+
.toHaveAttribute("aria-checked", "true");
172+
await expect
173+
.element(page.getByRole("menuitemradio", { name: "Auto", exact: true }))
174+
.not.toBeInTheDocument();
175+
await userEvent.click(page.getByRole("menuitemradio", { name: "Supervised" }));
176+
const trigger = screen.getByRole("button", { name: "Permissions: Supervised" });
177+
await expect.element(trigger).toBeVisible();
178+
await expect
179+
.element(page.getByRole("menuitemradio", { name: "Supervised" }))
180+
.not.toBeInTheDocument();
181+
await userEvent.click(trigger);
182+
await expect
183+
.element(page.getByRole("menuitemradio", { name: "Supervised" }))
184+
.toHaveAttribute("aria-checked", "true");
185+
await userEvent.keyboard("{Escape}");
186+
await expect.element(trigger).toHaveFocus();
187+
});

‎apps/web/src/components/chat/runtimeModeConfig.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import type { RuntimeMode } from "@t3tools/contracts";
1+
import {
2+
managedRuntimeModeDenial,
3+
type ManagedPolicySummary,
4+
type RuntimeMode,
5+
} from "@t3tools/contracts";
26
import { type LucideIcon, LockIcon, PenLineIcon, ShieldOffIcon, SparklesIcon } from "lucide-react";
37

48
export const runtimeModeConfig: Record<
@@ -41,3 +45,14 @@ export function resolveCompatibleRuntimeMode(
4145
: runtimeModeOptions;
4246
return { modes, mode: modes.includes(runtimeMode) ? runtimeMode : (modes[0] ?? runtimeMode) };
4347
}
48+
49+
export function resolveRuntimeModeOptions(
50+
modes: ReadonlyArray<RuntimeMode>,
51+
managedPolicy: ManagedPolicySummary | undefined,
52+
) {
53+
return modes.map((mode) => {
54+
const option = { mode, ...runtimeModeConfig[mode] };
55+
const disabledReason = managedRuntimeModeDenial(managedPolicy, mode);
56+
return disabledReason === undefined ? option : { ...option, disabledReason };
57+
});
58+
}

0 commit comments

Comments
 (0)