Skip to content
Open
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
82 changes: 81 additions & 1 deletion src/web/pages/AskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import {
triggerAuthReload,
} from "../lib/api.js";
import { APP_API_BASE } from "../lib/paths.js";
import {
ASK_SIDEBAR_MAX_WIDTH,
ASK_SIDEBAR_MIN_WIDTH,
useUiPrefsStore,
} from "../stores/ui-prefs-store.js";

/**
* Global Ask chat. Left column: thread list. Right column: messages for
Expand All @@ -33,6 +38,61 @@ export function AskPage() {
// thread's origin so we can gate the composer.
const [activeThreadOrigin, setActiveThreadOrigin] = useState<"web" | "telegram" | null>(null);

// Persisted, resizable thread sidebar. The drag handler attaches its
// listeners to `document` for the duration of the drag so a fast
// mouse movement that leaves the handle's hitbox keeps tracking.
const sidebarWidth = useUiPrefsStore((s) => s.askSidebarWidth);
const setSidebarWidth = useUiPrefsStore((s) => s.setAskSidebarWidth);
const onSidebarResizeStart = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = sidebarWidth;
const onMove = (e: MouseEvent) => {
setSidebarWidth(startWidth + (e.clientX - startX));
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
// Keep the resize cursor visible across the whole window while
// dragging, and stop the browser from selecting text under the
// pointer.
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[sidebarWidth, setSidebarWidth],
);
const onSidebarResizeKey = useCallback(
(event: React.KeyboardEvent) => {
const STEP = 16;
let next = sidebarWidth;
switch (event.key) {
case "ArrowLeft":
next = sidebarWidth - STEP;
break;
case "ArrowRight":
next = sidebarWidth + STEP;
break;
case "Home":
next = ASK_SIDEBAR_MIN_WIDTH;
break;
case "End":
next = ASK_SIDEBAR_MAX_WIDTH;
break;
default:
return;
}
event.preventDefault();
setSidebarWidth(next);
},
[sidebarWidth, setSidebarWidth],
);

const reloadThreads = useCallback(async () => {
try {
const res = await api.getAskThreads();
Expand Down Expand Up @@ -226,7 +286,10 @@ export function AskPage() {
return (
<div className="flex h-full min-h-0">
{/* Thread sidebar */}
<aside className="hidden md:flex w-60 flex-shrink-0 flex-col border-r border-border bg-card/30">
<aside
className="hidden md:flex flex-shrink-0 flex-col border-r border-border bg-card/30"
style={{ width: `${sidebarWidth}px` }}
>
<div className="p-3 border-b border-border">
<button
type="button"
Expand Down Expand Up @@ -283,6 +346,23 @@ export function AskPage() {
</div>
</aside>

{/* Drag handle to resize the thread sidebar. Only rendered when
the sidebar itself is visible (md+). The handle is keyboard-
accessible too — Left/Right arrows step the width by 16px,
Home/End jump to the clamp endpoints. */}
<div
role="separator"
aria-label="Resize thread sidebar"
aria-orientation="vertical"
aria-valuemin={ASK_SIDEBAR_MIN_WIDTH}
aria-valuemax={ASK_SIDEBAR_MAX_WIDTH}
aria-valuenow={sidebarWidth}
tabIndex={0}
onMouseDown={onSidebarResizeStart}
onKeyDown={onSidebarResizeKey}
className="hidden md:block w-1 cursor-col-resize bg-transparent hover:bg-border focus:bg-border focus:outline-none transition-colors"
/>

{/* Conversation column */}
<div className="flex-1 flex flex-col min-w-0">
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
Expand Down
32 changes: 31 additions & 1 deletion src/web/stores/ui-prefs-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,37 @@ import { create } from "zustand";

const STORAGE_KEY = "agentpulse.uiPrefs";

export const ASK_SIDEBAR_MIN_WIDTH = 180;
export const ASK_SIDEBAR_MAX_WIDTH = 480;
export const ASK_SIDEBAR_DEFAULT_WIDTH = 240;

interface UiPrefs {
/**
* Tint session cards + tabs by project (last path segment of cwd).
* Default on — the tint helps group work visually in a multi-repo
* dashboard, and is subtle enough not to fight the rest of the UI.
*/
projectColors: boolean;

/**
* Width (px) of the thread sidebar on the /ask page. Persisted so a
* resize survives reloads. Clamped to [ASK_SIDEBAR_MIN_WIDTH,
* ASK_SIDEBAR_MAX_WIDTH] when read or set so a stale value can't push
* the sidebar off-screen or shrink it below the "New conversation"
* button's minimum legible width.
*/
askSidebarWidth: number;
}

function clampAskSidebarWidth(width: unknown): number {
const numeric = typeof width === "number" ? width : Number(width);
if (!Number.isFinite(numeric)) return ASK_SIDEBAR_DEFAULT_WIDTH;
return Math.min(ASK_SIDEBAR_MAX_WIDTH, Math.max(ASK_SIDEBAR_MIN_WIDTH, Math.round(numeric)));
}

const DEFAULTS: UiPrefs = {
projectColors: true,
askSidebarWidth: ASK_SIDEBAR_DEFAULT_WIDTH,
};

function load(): UiPrefs {
Expand All @@ -28,7 +48,11 @@ function load(): UiPrefs {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULTS;
const parsed = JSON.parse(raw) as Partial<UiPrefs>;
return { ...DEFAULTS, ...parsed };
return {
...DEFAULTS,
...parsed,
askSidebarWidth: clampAskSidebarWidth(parsed.askSidebarWidth ?? DEFAULTS.askSidebarWidth),
};
} catch {
return DEFAULTS;
}
Expand All @@ -44,6 +68,7 @@ function save(prefs: UiPrefs): void {

interface UiPrefsStore extends UiPrefs {
setProjectColors: (enabled: boolean) => void;
setAskSidebarWidth: (width: number) => void;
}

export const useUiPrefsStore = create<UiPrefsStore>((set, get) => ({
Expand All @@ -52,4 +77,9 @@ export const useUiPrefsStore = create<UiPrefsStore>((set, get) => ({
set({ projectColors: enabled });
save({ ...get(), projectColors: enabled });
},
setAskSidebarWidth(width) {
const clamped = clampAskSidebarWidth(width);
set({ askSidebarWidth: clamped });
save({ ...get(), askSidebarWidth: clamped });
},
}));