Skip to content

Commit b0df83d

Browse files
committed
feat(webapp): account preference for the chat open position
1 parent c7cc318 commit b0df83d

8 files changed

Lines changed: 236 additions & 43 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
You can now set your preferred Ask Trigger chat position (floating, right panel, or fullscreen) in your account settings — it's used every time you open the chat.

apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useEnvironment } from "~/hooks/useEnvironment";
1111
import { useOrganization } from "~/hooks/useOrganizations";
1212
import { useProject } from "~/hooks/useProject";
1313
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
14+
import { useUser } from "~/hooks/useUser";
1415
import { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
1516
import { agentDeepLinkParams, ASK_AI_SHORTCUT, askAiChannelTarget } from "./ask-ai-channels";
1617
import { DashboardAgentPanel } from "./DashboardAgentPanel";
@@ -19,8 +20,7 @@ import { useDashboardAgentOpenRequests } from "./dashboardAgentOpenRequest";
1920
import {
2021
agentHiddenContentClassName,
2122
FloatingAgentWindow,
22-
readAgentMode,
23-
writeAgentMode,
23+
initialAgentMode,
2424
type DashboardAgentMode,
2525
} from "./panel-layout";
2626
import { nextPendingTurnChatId } from "./pending-turn";
@@ -37,6 +37,10 @@ import {
3737

3838
const TOASTED_WAKES_STORAGE_KEY = "tdev:dashboard-agent:toasted-wakes";
3939

40+
// Superseded by the account preference; a stray value here would otherwise pin the mode
41+
// forever if this cleanup effect never ran.
42+
const STALE_MODE_STORAGE_KEYS = ["tdev:dashboard-agent:mode", "tdev:dashboard-agent:fullscreen"];
43+
4044
// Shorter than the poll interval, so a stuck request is dropped before the next tick.
4145
const UNREAD_REQUEST_TIMEOUT_MS = 30_000;
4246

@@ -62,6 +66,8 @@ export function DashboardAgent({
6266
const organization = useOrganization();
6367
const project = useProject();
6468
const environment = useEnvironment();
69+
const user = useUser();
70+
const modePreference = user.dashboardPreferences.chatOpenMode;
6571
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
6672

6773
const [open, setOpen] = useState(false);
@@ -116,27 +122,32 @@ export function DashboardAgent({
116122
setUnreadWakes(initialUnreadWakes);
117123
setUnreadWork(initialUnreadWork);
118124
}, [environment.id, initialUnreadWakes, initialUnreadWork]);
119-
// Read lazily: SSR has no localStorage, so the server always renders the floating default.
120-
const [mode, setMode] = useState<DashboardAgentMode>(readAgentMode);
125+
// Every open starts from the account preference; in-chat switches (toggle, drag-to-dock)
126+
// are transient and never write it back.
127+
const [mode, setMode] = useState<DashboardAgentMode>(() => initialAgentMode(modePreference));
121128
const fullscreen = mode === "fullscreen";
122129

123130
const changeMode = useCallback((next: DashboardAgentMode) => {
124-
writeAgentMode(next);
125131
setMode(next);
126132
}, []);
127133

134+
// Superseded localStorage keys; harmless to skip if storage is unavailable.
135+
useEffect(() => {
136+
try {
137+
for (const key of STALE_MODE_STORAGE_KEYS) window.localStorage.removeItem(key);
138+
} catch {
139+
/* ignore */
140+
}
141+
}, []);
142+
128143
// Pathname only: filter and search-param changes must keep fullscreen.
129144
const { pathname } = useLocation();
130145
const previousPathname = useRef(pathname);
131146
useEffect(() => {
132147
if (previousPathname.current === pathname) return;
133148
previousPathname.current = pathname;
134-
setMode((current) => {
135-
if (current !== "fullscreen") return current;
136-
writeAgentMode("floating");
137-
return "floating";
138-
});
139-
}, [pathname]);
149+
setMode((current) => (current !== "fullscreen" ? current : initialAgentMode(modePreference)));
150+
}, [pathname, modePreference]);
140151
const [newChatSeq, setNewChatSeq] = useState(0);
141152
const [requestedMessage, setRequestedMessage] = useState<
142153
{ text: string; seq: number } | undefined
@@ -167,16 +178,14 @@ export function DashboardAgent({
167178
setOpen(false);
168179
// Pending requests must be dropped or a stale one re-applies on the next open.
169180
visibleChat.current = null;
170-
setMode((current) => {
171-
if (current !== "fullscreen") return current;
172-
writeAgentMode("floating");
173-
return "floating";
174-
});
181+
// Any transient in-chat mode switch applied only until close; the next open
182+
// starts from the account preference again.
183+
setMode(initialAgentMode(modePreference));
175184
setRequestedMessage(undefined);
176185
setOpenChatRequest(undefined);
177186
setWatchRequest(undefined);
178187
},
179-
[openPanel]
188+
[openPanel, modePreference]
180189
);
181190

182191
const openChat = useCallback(
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// @vitest-environment jsdom
2+
import { createElement, useCallback, useState } from "react";
3+
import { createRoot, type Root } from "react-dom/client";
4+
import { act } from "react-dom/test-utils";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { initialAgentMode, type DashboardAgentMode } from "./panel-layout";
7+
8+
describe("initialAgentMode", () => {
9+
it("opens in the account preference when one is set", () => {
10+
expect(initialAgentMode("rightPanel")).toBe("rightPanel");
11+
expect(initialAgentMode("fullscreen")).toBe("fullscreen");
12+
});
13+
14+
it("defaults to floating when there is no preference", () => {
15+
expect(initialAgentMode(undefined)).toBe("floating");
16+
});
17+
});
18+
19+
type HarnessHandle = {
20+
mode: DashboardAgentMode;
21+
changeMode: (mode: DashboardAgentMode) => void;
22+
close: () => void;
23+
reopen: () => void;
24+
};
25+
26+
let container: HTMLDivElement | undefined;
27+
let root: Root | undefined;
28+
29+
afterEach(() => {
30+
if (root) act(() => root!.unmount());
31+
container?.remove();
32+
container = undefined;
33+
root = undefined;
34+
});
35+
36+
// Mirrors DashboardAgent.tsx's own state shape: mode starts from the preference, an
37+
// in-chat switch is transient (setMode only), and closing reverts to the preference —
38+
// so the next open starts clean regardless of what the last session left it on.
39+
function renderHarness(preference: DashboardAgentMode | undefined) {
40+
let latest!: HarnessHandle;
41+
function Harness() {
42+
const [mode, setMode] = useState<DashboardAgentMode>(() => initialAgentMode(preference));
43+
44+
const changeMode = useCallback((next: DashboardAgentMode) => setMode(next), []);
45+
const close = useCallback(() => setMode(initialAgentMode(preference)), []);
46+
const reopen = useCallback(() => {}, []);
47+
48+
// oxlint-disable-next-line react/globals -- test harness capturing the latest state/handlers.
49+
latest = { mode, changeMode, close, reopen };
50+
return null;
51+
}
52+
container = document.createElement("div");
53+
document.body.appendChild(container);
54+
root = createRoot(container);
55+
act(() => {
56+
root!.render(createElement(Harness));
57+
});
58+
return {
59+
get current() {
60+
return latest;
61+
},
62+
};
63+
}
64+
65+
describe("a transient in-chat mode switch reverts on close", () => {
66+
it("switching mode while open, then closing and reopening, lands back on the preference", () => {
67+
const harness = renderHarness("rightPanel");
68+
69+
expect(harness.current.mode).toBe("rightPanel");
70+
71+
act(() => harness.current.changeMode("fullscreen"));
72+
expect(harness.current.mode).toBe("fullscreen");
73+
74+
act(() => harness.current.close());
75+
act(() => harness.current.reopen());
76+
77+
expect(harness.current.mode).toBe("rightPanel");
78+
});
79+
});

apps/webapp/app/components/dashboard-agent/panel-layout.tsx

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type Point,
1616
type Rect,
1717
} from "~/components/primitives/draggableResizableMath";
18+
import type { ChatOpenMode } from "~/utils/dashboardPreferences";
1819
import { cn } from "~/utils/cn";
1920

2021
// Mark an element (e.g. a header button, or just its icon) with `data-agent-no-drag` so a
@@ -27,10 +28,7 @@ export type FloatingDragProps = {
2728
dragHandleClassName: string;
2829
};
2930

30-
const AGENT_FULLSCREEN_STORAGE_KEY = "tdev:dashboard-agent:fullscreen";
31-
const AGENT_MODE_STORAGE_KEY = "tdev:dashboard-agent:mode";
32-
33-
export type DashboardAgentMode = "floating" | "rightPanel" | "fullscreen";
31+
export type DashboardAgentMode = ChatOpenMode;
3432

3533
// V1 floating window: FLOATING_WIDTH x FLOATING_HEIGHT, bottom-right, matching the
3634
// gallery's own panel frame.
@@ -62,28 +60,10 @@ export function initialFloatingRect() {
6260
};
6361
}
6462

65-
// Reads the old boolean key once, so a browser that only ever knew fullscreen keeps its
66-
// choice after the upgrade to three modes.
67-
export function readAgentMode(): DashboardAgentMode {
68-
if (typeof window === "undefined") return "floating";
69-
try {
70-
const stored = window.localStorage.getItem(AGENT_MODE_STORAGE_KEY);
71-
if (stored === "floating" || stored === "rightPanel" || stored === "fullscreen") return stored;
72-
return window.localStorage.getItem(AGENT_FULLSCREEN_STORAGE_KEY) === "true"
73-
? "fullscreen"
74-
: "floating";
75-
} catch {
76-
return "floating";
77-
}
78-
}
79-
80-
export function writeAgentMode(mode: DashboardAgentMode): void {
81-
if (typeof window === "undefined") return;
82-
try {
83-
window.localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
84-
} catch {
85-
/* ignore */
86-
}
63+
/** The mode a chat starts in, and the mode a transient in-chat switch reverts to: the
64+
* account preference, defaulting to floating. */
65+
export function initialAgentMode(preference: DashboardAgentMode | undefined): DashboardAgentMode {
66+
return preference ?? "floating";
8767
}
8868

8969
function agentTakeoverClassName(fullscreen: boolean): string {

apps/webapp/app/routes/account._index/route.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
} from "@remix-run/server-runtime";
99
import { z } from "zod";
1010
import { EditPencilIcon } from "~/assets/icons/EditPencilIcon";
11+
import { ChatFloatingPanel } from "~/assets/icons/ChatFloatingPanel";
12+
import { ChatFullScreen } from "~/assets/icons/ChatFullScreen";
13+
import { ChatRightPanel } from "~/assets/icons/ChatRightPanel";
1114
import { UserProfilePhoto } from "~/components/UserProfilePhoto";
1215
import {
1316
MainHorizontallyCenteredContainer,
@@ -24,6 +27,7 @@ import {
2427
DialogTrigger,
2528
} from "~/components/primitives/Dialog";
2629
import { Select, SelectItem } from "~/components/primitives/Select";
30+
import SegmentedControl from "~/components/primitives/SegmentedControl";
2731
import { Slider } from "~/components/primitives/Slider";
2832
import { FormError } from "~/components/primitives/FormError";
2933
import { Header2 } from "~/components/primitives/Headers";
@@ -77,6 +81,7 @@ import {
7781
updateThemePreference,
7882
updateUnderlineLinksPreference,
7983
} from "~/services/dashboardPreferences.server";
84+
import type { ChatOpenMode } from "~/utils/dashboardPreferences";
8085
import {
8186
normalizeIconContrast,
8287
normalizeSystemDarkTheme,
@@ -778,6 +783,49 @@ function CustomizeSidebarButton({
778783
);
779784
}
780785

786+
const CHAT_OPEN_MODE_OPTIONS: {
787+
mode: ChatOpenMode;
788+
label: string;
789+
Icon: typeof ChatFloatingPanel;
790+
}[] = [
791+
{ mode: "floating", label: "Floating", Icon: ChatFloatingPanel },
792+
{ mode: "rightPanel", label: "Right panel", Icon: ChatRightPanel },
793+
{ mode: "fullscreen", label: "Fullscreen", Icon: ChatFullScreen },
794+
];
795+
796+
/** The mode Ask Trigger opens in; in-chat mode switches stay transient and don't change this. */
797+
function ChatOpenModePicker() {
798+
const user = useUser();
799+
const fetcher = useFetcher();
800+
const pending = fetcher.formData?.get("chatOpenMode");
801+
const current =
802+
typeof pending === "string"
803+
? (pending as ChatOpenMode)
804+
: (user.dashboardPreferences.chatOpenMode ?? "floating");
805+
806+
return (
807+
<SegmentedControl
808+
name="chat-open-mode"
809+
variant="secondary/small"
810+
value={current}
811+
options={CHAT_OPEN_MODE_OPTIONS.map(({ mode, label, Icon }) => ({
812+
value: mode,
813+
label: (
814+
<span className="flex items-center justify-center" aria-label={label} title={label}>
815+
<Icon className="size-4" />
816+
</span>
817+
),
818+
}))}
819+
onChange={(value) =>
820+
fetcher.submit(
821+
{ chatOpenMode: value },
822+
{ method: "POST", action: "/resources/preferences/chat-open-mode" }
823+
)
824+
}
825+
/>
826+
);
827+
}
828+
781829
export default function Page() {
782830
const user = useUser();
783831
const { showThemeSwitcher, sidebarContext } = useLoaderData<typeof loader>();
@@ -1094,6 +1142,17 @@ export default function Page() {
10941142
</div>
10951143
</div>
10961144
)}
1145+
<div className="flex min-h-16 w-full items-center border-b border-grid-dimmed">
1146+
<div className="flex w-full items-center justify-between gap-4">
1147+
<div className={cn("flex-1", SETTINGS_ROW_TITLE_GAP)}>
1148+
<Label>Ask Trigger chat</Label>
1149+
<SettingsRowDescription>Choose where the chat opens</SettingsRowDescription>
1150+
</div>
1151+
<div className="flex flex-none items-center">
1152+
<ChatOpenModePicker />
1153+
</div>
1154+
</div>
1155+
</div>
10971156
<div className="flex min-h-16 w-full items-center border-b border-grid-dimmed">
10981157
<div className="flex w-full items-center justify-between gap-4">
10991158
<div className={cn("flex-1", SETTINGS_ROW_TITLE_GAP)}>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { json, type ActionFunctionArgs } from "@remix-run/node";
2+
import { updateChatOpenModePreference } from "~/services/dashboardPreferences.server";
3+
import { requireUser } from "~/services/session.server";
4+
import { ChatOpenMode } from "~/utils/dashboardPreferences";
5+
6+
export async function action({ request }: ActionFunctionArgs) {
7+
const user = await requireUser(request);
8+
9+
if (user.isImpersonating) {
10+
return json({ success: false, error: "Not available" }, { status: 403 });
11+
}
12+
13+
const formData = await request.formData();
14+
// Strict, not normalized: an unknown value must fail rather than reset.
15+
const chatOpenMode = ChatOpenMode.safeParse(formData.get("chatOpenMode"));
16+
if (!chatOpenMode.success) {
17+
return json({ success: false, error: "Invalid mode" }, { status: 400 });
18+
}
19+
20+
await updateChatOpenModePreference({ user, chatOpenMode: chatOpenMode.data });
21+
22+
return json({ success: true });
23+
}

0 commit comments

Comments
 (0)