feat(web): group the chat sidebar by workspace with an agent-mode toggle (#26)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,8 +17,10 @@
|
||||
* change before unmount, one final write is flushed) — closing and returning to
|
||||
* the page resumes where you left off; the cache is cleared on successful send.
|
||||
* The sidebar group header "+" / menu "New conversation" explicitly specify an
|
||||
* Agent via route state (overriding the cached selection); a direct visit or
|
||||
* refresh falls back to the cache.
|
||||
* Agent via route state (overriding the cached selection); the workspace-mode
|
||||
* group header "+" additionally carries a Workspace path pre-filling the
|
||||
* Workspace selection ("" = auto temp directory). A direct visit or refresh
|
||||
* falls back to the cache.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
@@ -54,6 +56,32 @@ import { sameModelRef } from "../models/model-grouping";
|
||||
/** Coalescing window for writing body text to the cache: keystrokes are frequent, so a short batch accumulates before persisting (option changes are still written immediately). */
|
||||
const DRAFT_SAVE_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* "Applied" markers for the route-state overrides (one slot per field, holding the last
|
||||
* consumed location.key). React Router persists location.state AND location.key in
|
||||
* history.state, which survives a full page reload, while a ref resets with the JS
|
||||
* context — with a ref alone, a reload would re-apply the override and clobber whatever
|
||||
* the user changed since (restored from the draft cache). sessionStorage is per-tab
|
||||
* exactly like history.state, so the marker follows the history entry; on storage
|
||||
* failure (private mode) both helpers degrade to "not consumed", and the in-component
|
||||
* ref still provides the previous apply-once-per-mount behavior.
|
||||
*/
|
||||
type RouteStateField = "agentId" | "workspace";
|
||||
function loadAppliedRouteKey(field: RouteStateField): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(`penguin.chatRouteApplied.${field}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function saveAppliedRouteKey(field: RouteStateField, key: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(`penguin.chatRouteApplied.${field}`, key);
|
||||
} catch {
|
||||
/* best-effort: the dedup marker falls back to the per-mount ref */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example tasks on the draft screen, in display order (game card first, LoL music player,
|
||||
* then the RAG build). Copy lives in S.chat.exampleTasks[id]; skills are pinned via a
|
||||
@@ -124,14 +152,20 @@ export function DraftView({
|
||||
// on invalid value" effect would let the former write B in one render while the
|
||||
// latter, still judging by the stale closure's invalid value, writes the default
|
||||
// Agent and clobbers B.
|
||||
const stateAgentId = (location.state as { agentId?: string } | null)?.agentId;
|
||||
const routeState = location.state as { agentId?: string; workspace?: string } | null;
|
||||
const stateAgentId = routeState?.agentId;
|
||||
const appliedStateKey = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) return; // list not ready yet, nothing to validate against — wait for the next pass
|
||||
const valid = (id: string | null | undefined): id is string =>
|
||||
!!id && agents.some((a) => a.agentId === id);
|
||||
if (stateAgentId && appliedStateKey.current !== location.key) {
|
||||
if (
|
||||
stateAgentId &&
|
||||
appliedStateKey.current !== location.key &&
|
||||
loadAppliedRouteKey("agentId") !== location.key
|
||||
) {
|
||||
appliedStateKey.current = location.key;
|
||||
saveAppliedRouteKey("agentId", location.key);
|
||||
if (valid(stateAgentId)) {
|
||||
setAgentId(stateAgentId);
|
||||
return;
|
||||
@@ -141,6 +175,25 @@ export function DraftView({
|
||||
setAgentId((agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId ?? null);
|
||||
}, [agents, agentId, location.key, stateAgentId]);
|
||||
|
||||
// Explicit Workspace from route state (the workspace-mode group header "+"): applied once per
|
||||
// location.key, same convention as the Agent above, overriding the cached selection ("" pre-fills
|
||||
// the auto temp directory). Unlike the Agent there's no list to validate against, so this is a
|
||||
// separate effect that never has to wait for a load.
|
||||
const stateWorkspace = routeState?.workspace;
|
||||
const appliedWorkspaceKey = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (
|
||||
stateWorkspace === undefined ||
|
||||
appliedWorkspaceKey.current === location.key ||
|
||||
loadAppliedRouteKey("workspace") === location.key
|
||||
) {
|
||||
return;
|
||||
}
|
||||
appliedWorkspaceKey.current = location.key;
|
||||
saveAppliedRouteKey("workspace", location.key);
|
||||
setWorkspace(stateWorkspace);
|
||||
}, [location.key, stateWorkspace]);
|
||||
|
||||
// Model fallback: once config is ready, if nothing is selected or the selection is no longer valid, fall back to the project default → the first model (always as a paired reference).
|
||||
useEffect(() => {
|
||||
if (!models) return;
|
||||
|
||||
Reference in New Issue
Block a user