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:
Yaowei Zheng
2026-07-22 22:22:07 +08:00
committed by GitHub
parent 3806e4e441
commit 99f391f379
8 changed files with 656 additions and 130 deletions
+57 -4
View File
@@ -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;