Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* Draft view (/chat/new): the pre-persistence form of a new
|
||||
* conversation, before any Session exists. The input card sits vertically centered;
|
||||
* before sending, this is where Agent / Workspace / approval mode / Model are all
|
||||
* chosen in one place — two small dropdown pills sit right below the card (pill
|
||||
* buttons, styled after ChatGPT's project picker): Agent selection and Workspace
|
||||
* directory selection (the menu browses server-side directories, and the current
|
||||
* path can be edited directly); the model picker lives in the input card's bottom
|
||||
* toolbar, left of the send button (with a vendor logo). The Session is only
|
||||
* created when **the first message is sent**; once created, Agent / Workspace /
|
||||
* Model are locked in via meta, and only approval mode remains editable (in the
|
||||
* session-mode input area).
|
||||
*
|
||||
* Draft auto-cache (storage and validation in draft-cache.ts; keys are isolated by
|
||||
* "user × Project", #68): the four selections are saved as soon as they change;
|
||||
* body text is keystroke-frequent and deferred/coalesced (if there's an unsaved
|
||||
* 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.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import type {
|
||||
AgentSummary,
|
||||
ApprovalMode,
|
||||
DirListResponse,
|
||||
ModelRefDto,
|
||||
ModelsResponse,
|
||||
SessionCreateRequest,
|
||||
SkillMetadataItem,
|
||||
TaskInputPart,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { agentDisplayName, useProject } from "../../state/project";
|
||||
import { useSessions } from "../../state/sessions";
|
||||
import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||||
import { Chevron } from "../../components/ui/chevron";
|
||||
import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { PenguinLogo } from "../../components/ui/penguin-logo";
|
||||
import { toastError } from "../../components/ui/toast";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
|
||||
import type { DraftCache } from "./draft-cache";
|
||||
import { handoffMessage } from "./agent-mentions";
|
||||
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;
|
||||
|
||||
export function DraftView({
|
||||
projectId,
|
||||
models,
|
||||
}: {
|
||||
projectId: string;
|
||||
/** Project model config (already fetched by ChatPage): candidate list and default model. */
|
||||
models: ModelsResponse | null;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { agents, currentAgent, setCurrentAgentId } = useProject();
|
||||
const { add } = useSessions();
|
||||
// The draft key includes a user dimension (#68 cross-account leakage). RequireAuth
|
||||
// guarantees the user is logged in here; on the off chance there's no user (the
|
||||
// type allows null), it's better to disable caching entirely than to read/write a
|
||||
// key that isn't account-scoped.
|
||||
const userId = useAuth().user?.userId ?? null;
|
||||
|
||||
// The cache is read only once, on mount: the component remounts keyed by Project,
|
||||
// so switching Projects automatically switches to the corresponding draft; switching
|
||||
// accounts always goes through logout (clearing the user unmounts the whole route
|
||||
// tree), so logging back in is likewise a fresh mount.
|
||||
const [cached] = useState<DraftCache>(() =>
|
||||
userId ? loadDraft(draftKey(userId, projectId)) : {},
|
||||
);
|
||||
|
||||
const [agentId, setAgentId] = useState<string | null>(
|
||||
cached.agentId ?? currentAgent?.agentId ?? null,
|
||||
);
|
||||
const [workspace, setWorkspace] = useState(cached.workspace ?? "");
|
||||
const [approvalMode, setApprovalMode] = useState<ApprovalMode>(
|
||||
cached.approvalMode ?? "allow-all",
|
||||
);
|
||||
const [modelRef, setModelRef] = useState<ModelRefDto | null>(cached.modelRef ?? null);
|
||||
/** @-handoff target (chip): draft content just like the body text, cached alongside it (fed in via the ChatInput callback). */
|
||||
const [handoffAgentId, setHandoffAgentId] = useState<string | null>(
|
||||
cached.handoffAgentId ?? null,
|
||||
);
|
||||
const textRef = useRef(cached.text ?? "");
|
||||
/**
|
||||
* Selected skills (prefilled by "quick invoke" from the Skills page + checked in
|
||||
* the input area): passed to ChatInput as the initial selection via initialSkills
|
||||
* on mount, then written back through onSkillsChange and persisted immediately
|
||||
* (discrete clicks) — survives a refresh; cleared along with the whole draft on
|
||||
* successful send, kept on failure so it can be resent.
|
||||
*/
|
||||
const skillsRef = useRef<string[]>(cached.skills ?? []);
|
||||
|
||||
// Unified resolution of the Agent selection (a single effect, single writer):
|
||||
// explicit route state > current valid value (from cache / panel selection) >
|
||||
// default_agent > the first one. Explicit intent (sidebar group header "+" / menu
|
||||
// "New conversation") is applied only once per location.key — clicking "+" again
|
||||
// for the same Agent gets a new key and re-aligns, while the user's subsequent
|
||||
// reselection in the panel won't keep getting overridden. Merging this into one
|
||||
// effect is essential: splitting it into an "apply state" effect and a "fallback
|
||||
// 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 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) {
|
||||
appliedStateKey.current = location.key;
|
||||
if (valid(stateAgentId)) {
|
||||
setAgentId(stateAgentId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (valid(agentId)) return;
|
||||
setAgentId((agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId ?? null);
|
||||
}, [agents, agentId, location.key, stateAgentId]);
|
||||
|
||||
// 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;
|
||||
if (modelRef && models.models.some((m) => sameModelRef(m, modelRef))) return;
|
||||
const first = models.models[0];
|
||||
setModelRef(
|
||||
models.defaultModel ?? (first ? { provider: first.provider, modelId: first.modelId } : null),
|
||||
);
|
||||
}, [models, modelRef]);
|
||||
|
||||
// Skills installed on the currently selected Agent (candidates for the input
|
||||
// area's skills dropdown): switching Agents first clears the list (which also
|
||||
// clears the selection in the input area), then refetches; a fetch failure is
|
||||
// silently treated as no skills. Clearing preserves the reference when already
|
||||
// empty (doesn't swap in a new array): swapping the reference on the very first
|
||||
// mount render would trigger ChatInput's pruning effect and wrongly clear the
|
||||
// quick-invoke preselection.
|
||||
const [agentSkills, setAgentSkills] = useState<SkillMetadataItem[]>([]);
|
||||
useEffect(() => {
|
||||
setAgentSkills((prev) => (prev.length > 0 ? [] : prev));
|
||||
if (!agentId) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.getAgentSkills(projectId, agentId)
|
||||
.then((res) => {
|
||||
if (!cancelled) setAgentSkills(res.skills);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, agentId]);
|
||||
|
||||
// —— Auto-cache ——
|
||||
// Options (Agent / Workspace / approval mode / Model) are discrete clicks: written
|
||||
// immediately on change; body text is keystroke-frequent: debounced trailing write,
|
||||
// with a final flush on unmount if there's an unsaved change.
|
||||
const saveTimer = useRef<number | null>(null);
|
||||
const cancelPendingSave = useCallback(() => {
|
||||
if (saveTimer.current !== null) {
|
||||
window.clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistNow = useCallback(() => {
|
||||
cancelPendingSave();
|
||||
if (!userId) return;
|
||||
const data: DraftCache = { text: textRef.current, workspace, approvalMode };
|
||||
if (agentId) data.agentId = agentId;
|
||||
if (modelRef) data.modelRef = modelRef;
|
||||
if (handoffAgentId) data.handoffAgentId = handoffAgentId;
|
||||
if (skillsRef.current.length > 0) data.skills = skillsRef.current;
|
||||
saveDraft(draftKey(userId, projectId), data);
|
||||
}, [
|
||||
cancelPendingSave,
|
||||
userId,
|
||||
projectId,
|
||||
agentId,
|
||||
workspace,
|
||||
approvalMode,
|
||||
modelRef,
|
||||
handoffAgentId,
|
||||
]);
|
||||
|
||||
// The timer and unmount cleanup read persistNow via a ref to always get the **latest version**: a stale closure would write back outdated options.
|
||||
const persistRef = useRef(persistNow);
|
||||
useEffect(() => {
|
||||
persistRef.current = persistNow;
|
||||
// Write immediately on option change (also writes once on mount, idempotently).
|
||||
persistNow();
|
||||
}, [persistNow]);
|
||||
|
||||
const onTextChange = useCallback(
|
||||
(text: string) => {
|
||||
textRef.current = text;
|
||||
cancelPendingSave();
|
||||
saveTimer.current = window.setTimeout(() => {
|
||||
saveTimer.current = null;
|
||||
persistRef.current();
|
||||
}, DRAFT_SAVE_DEBOUNCE_MS);
|
||||
},
|
||||
[cancelPendingSave],
|
||||
);
|
||||
|
||||
/** Skill checklist change: writes back to the ref and persists immediately (discrete click, same convention as Agent/Model and other options). */
|
||||
const onSkillsChange = useCallback((names: string[]) => {
|
||||
skillsRef.current = names;
|
||||
persistRef.current();
|
||||
}, []);
|
||||
|
||||
// Unmount: if there's still unsaved body text, flush it (so a route change/page switch doesn't lose the last few keystrokes).
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveTimer.current !== null) {
|
||||
window.clearTimeout(saveTimer.current);
|
||||
persistRef.current();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** Discard the draft after a successful send: first cancels the pending save timer, otherwise it would write the just-cleared draft back. */
|
||||
const discardDraft = useCallback(() => {
|
||||
cancelPendingSave();
|
||||
// Clear the preselected skills too: any subsequent write (e.g. the unmount flush) must not resurrect a selection that's already been sent.
|
||||
skillsRef.current = [];
|
||||
if (userId) clearDraft(draftKey(userId, projectId));
|
||||
}, [cancelPendingSave, userId, projectId]);
|
||||
|
||||
const selectAgent = (a: AgentSummary) => {
|
||||
setAgentId(a.agentId);
|
||||
// Follow through to the global current Agent: keeps the sidebar memory and stats convention consistent.
|
||||
setCurrentAgentId(a.agentId);
|
||||
};
|
||||
|
||||
// First message sent: only now is the Session created (Agent / Workspace / Model / approval
|
||||
// mode are all locked in together), then the route jumps once sent; returns false on any
|
||||
// failure, so the input area keeps the draft and can resend.
|
||||
const onSend = useCallback(
|
||||
async (input: TaskInputPart[]): Promise<boolean> => {
|
||||
if (!agentId) return false;
|
||||
let createdId: string | null = null;
|
||||
try {
|
||||
const body: SessionCreateRequest = { approvalMode };
|
||||
// Model reference is submitted as a pair (provider + modelId; falls back to the Project default when not set).
|
||||
if (modelRef) {
|
||||
body.modelId = modelRef.modelId;
|
||||
body.provider = modelRef.provider;
|
||||
}
|
||||
if (workspace.trim()) body.workspace = workspace.trim();
|
||||
const created = await api.createSession(projectId, agentId, body);
|
||||
createdId = created.session.sessionId;
|
||||
const res = await api.postTask(createdId, { input });
|
||||
add(created.session);
|
||||
discardDraft();
|
||||
navigate(`/chat/${res.sessionId}`, { replace: true });
|
||||
return true;
|
||||
} catch (e) {
|
||||
// The Session was created but the first message failed to send (postTask failed): delete
|
||||
// this empty Session, otherwise every resend attempt would create another one, piling up
|
||||
// empty sessions with no messages in the sidebar (best-effort cleanup).
|
||||
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
|
||||
toastError(apiErrorText(e, modelRef ? { modelId: modelRef.modelId } : {}));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, agentId, approvalMode, modelRef, workspace, add, discardDraft, navigate],
|
||||
);
|
||||
|
||||
// @ handoff: opens a new chat for the @-mentioned agent (approval mode carries over from the
|
||||
// draft's current value; model/Workspace use the creation defaults), first input =
|
||||
// <handoff_from> source block + the text and images with the @ mention stripped.
|
||||
const selectedAgent = agents.find((a) => a.agentId === agentId) ?? null;
|
||||
const onHandoff = useCallback(
|
||||
async (target: AgentSummary, input: TaskInputPart[]): Promise<boolean> => {
|
||||
if (!selectedAgent) return false;
|
||||
const origin: TaskInputPart = {
|
||||
type: "text",
|
||||
text: handoffMessage({
|
||||
agentId: selectedAgent.agentId,
|
||||
...(selectedAgent.name !== undefined ? { agentName: selectedAgent.name } : {}),
|
||||
}),
|
||||
};
|
||||
let createdId: string | null = null;
|
||||
try {
|
||||
const created = await api.createSession(projectId, target.agentId, { approvalMode });
|
||||
createdId = created.session.sessionId;
|
||||
const res = await api.postTask(createdId, { input: [origin, ...input] });
|
||||
add(created.session);
|
||||
discardDraft();
|
||||
navigate(`/chat/${res.sessionId}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
|
||||
// The new chat uses the project's default model (createSession doesn't specify a model reference), so the error copy's model context follows suit.
|
||||
toastError(
|
||||
apiErrorText(e, models?.defaultModel ? { modelId: models.defaultModel.modelId } : {}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[projectId, selectedAgent, approvalMode, add, discardDraft, navigate, models],
|
||||
);
|
||||
|
||||
// Capability info for the currently selected model (vision/context window) switches instantly with the selection (matched by paired reference).
|
||||
const modelInfo = models?.models.find((m) => sameModelRef(m, modelRef));
|
||||
const contextWindow = modelInfo?.contextWindow;
|
||||
const vision = modelInfo?.vision !== false;
|
||||
|
||||
return (
|
||||
<div className="anim-fade flex min-h-0 flex-1 flex-col overflow-y-auto px-3 md:px-4">
|
||||
{/*
|
||||
* Vertical layout: two symmetric flex-1 spaces above and below, so **the input card lands
|
||||
* exactly on the viewport's centerline**; the brand area sits at the bottom of the upper
|
||||
* space (right above the input card). When the viewport is too short, the upper space
|
||||
* shrinks to the brand area's height, the lower space compresses to padding, and the whole
|
||||
* page falls back to natural document-flow scrolling (without clipping the top).
|
||||
*/}
|
||||
<div className="flex flex-1 flex-col justify-end pt-6">
|
||||
{/* Large brand logo + brand name + subtitle (e2e tests identify the draft page by this
|
||||
heading). The asset is square-cropped and the graphic already has a bit of built-in
|
||||
padding, so a small margin is enough to sit visually close to the title. */}
|
||||
<div className="mb-10 text-center">
|
||||
<PenguinLogo className="mx-auto mb-1 h-36 w-36 rounded-3xl" />
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-gray-900 dark:text-gray-100">
|
||||
{S.appName}
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-gray-400 dark:text-gray-500">{S.chat.draftSubtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<ChatInput
|
||||
status="idle"
|
||||
onSend={onSend}
|
||||
onStop={async () => undefined}
|
||||
onCompact={async () => undefined}
|
||||
modelRef={modelRef}
|
||||
models={models?.models ?? []}
|
||||
onChangeModel={setModelRef}
|
||||
{...(models?.defaultModel !== undefined ? { defaultModel: models.defaultModel } : {})}
|
||||
{...(contextWindow !== undefined ? { contextWindow } : {})}
|
||||
contextNow={0}
|
||||
vision={vision}
|
||||
approvalMode={approvalMode}
|
||||
onChangeApprovalMode={setApprovalMode}
|
||||
modeSaving={false}
|
||||
autoFocus
|
||||
agents={agents}
|
||||
skills={agentSkills}
|
||||
{...(cached.skills && cached.skills.length > 0 ? { initialSkills: cached.skills } : {})}
|
||||
onSkillsChange={onSkillsChange}
|
||||
onHandoff={onHandoff}
|
||||
initialText={cached.text ?? ""}
|
||||
onTextChange={onTextChange}
|
||||
{...(cached.handoffAgentId ? { initialHandoffTargetId: cached.handoffAgentId } : {})}
|
||||
onHandoffTargetChange={setHandoffAgentId}
|
||||
/>
|
||||
|
||||
{/* Ownership selection right below the card (small pill dropdowns, styled after ChatGPT's project picker button) */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<AgentSelect agents={agents} selected={selectedAgent} onSelect={selectAgent} />
|
||||
<WorkspaceSelect projectId={projectId} workspace={workspace} onChange={setWorkspace} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lower symmetric space (compresses to padding at minimum height) */}
|
||||
<div className="flex-1 pb-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared style for pill trigger buttons (ChatGPT project button style: small rounded pill + icon + short name + collapse arrow). */
|
||||
const pillClass =
|
||||
"flex max-w-64 items-center gap-1.5 rounded-full border border-gray-300 bg-white py-1 pl-1.5 pr-2 " +
|
||||
"text-xs text-gray-600 transition-colors duration-150 hover:bg-gray-50 hover:text-gray-900 " +
|
||||
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100";
|
||||
|
||||
/** Agent selection (pill dropdown): avatar + name, menu opens downward with an internal scroll cap. */
|
||||
function AgentSelect({
|
||||
agents,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
agents: AgentSummary[];
|
||||
selected: AgentSummary | null;
|
||||
onSelect: (agent: AgentSummary) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
menuClass="left-0 top-full mt-1 w-72 max-w-[calc(100vw-2rem)] origin-top-left"
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
title={S.chat.chooseAgent}
|
||||
aria-label={S.chat.chooseAgent}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={pillClass}
|
||||
>
|
||||
{selected ? (
|
||||
<AgentAvatar id={selected.agentId} size={16} className="shrink-0 rounded" />
|
||||
) : null}
|
||||
<span className="min-w-0 truncate">
|
||||
{selected ? agentDisplayName(selected) : S.common.loading}
|
||||
</span>
|
||||
<Chevron open={open} size={12} className="shrink-0 text-gray-400" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{agents.length === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-gray-400">{S.common.loading}</p>
|
||||
)}
|
||||
{agents.map((a) => {
|
||||
const active = a.agentId === selected?.agentId;
|
||||
return (
|
||||
<button
|
||||
key={a.agentId}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
onSelect(a);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<AgentAvatar id={a.agentId} size={20} className="shrink-0 rounded" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={`block truncate text-xs ${
|
||||
active
|
||||
? "font-medium text-gray-900 dark:text-gray-100"
|
||||
: "text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{agentDisplayName(a)}
|
||||
</span>
|
||||
{a.description && (
|
||||
<span className="block truncate text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{a.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="w-4 shrink-0 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{active ? "✓" : ""}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace selection (pill dropdown): the button shows the selected directory name (empty =
|
||||
* auto temporary directory). The menu browses server-side directories: **the current path can be
|
||||
* edited directly** at the top (Enter/blur commits it, an invalid directory toasts and reverts
|
||||
* to the previous path), the list omits hidden directories, and the hint text sits at the bottom
|
||||
* of the menu; only loads on first expand.
|
||||
*/
|
||||
function WorkspaceSelect({
|
||||
projectId,
|
||||
workspace,
|
||||
onChange,
|
||||
}: {
|
||||
projectId: string;
|
||||
workspace: string;
|
||||
onChange: (path: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const browsedRef = useRef(false);
|
||||
|
||||
const [dir, setDir] = useState<DirListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Edit draft for the path row: synced with the browsing position, reverts on a failed commit. */
|
||||
const [pathDraft, setPathDraft] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setPathDraft(dir?.path ?? "");
|
||||
}, [dir]);
|
||||
|
||||
/** Browses level by level (clicking a directory/parent); an empty string means the server's home directory (the default starting point). */
|
||||
const loadDir = useCallback(
|
||||
(abs: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.listDirs(projectId, abs)
|
||||
.then(setDir)
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError))
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
// Only loads on first expand: an already-filled absolute path is used as the starting point, otherwise the server falls back to the home directory.
|
||||
if (next && !browsedRef.current) {
|
||||
browsedRef.current = true;
|
||||
const ws = workspace.trim();
|
||||
loadDir(ws.startsWith("/") ? ws : "");
|
||||
}
|
||||
};
|
||||
|
||||
/** Commits the edited path: navigates to it if it exists, otherwise toasts and reverts to the current browsing position. */
|
||||
const commitPathEdit = async () => {
|
||||
const p = pathDraft.trim();
|
||||
if (!p || p === dir?.path) {
|
||||
setPathDraft(dir?.path ?? "");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setDir(await api.listDirs(projectId, p));
|
||||
} catch {
|
||||
toastError(S.chat.workspaceDirInvalid);
|
||||
setPathDraft(dir?.path ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const trimmed = workspace.trim();
|
||||
// Pill short name: the last segment of the directory name (root gives "/"); shows "auto temp directory" when empty.
|
||||
const label = trimmed ? (trimmed.split("/").filter(Boolean).pop() ?? "/") : S.chat.workspaceAuto;
|
||||
const parentPath = dir?.parent ?? null;
|
||||
// Hidden directories (starting with .) are excluded from the list.
|
||||
const entries = (dir?.entries ?? []).filter((e) => !e.name.startsWith("."));
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
menuClass="left-0 top-full mt-1 w-80 max-w-[calc(100vw-2rem)] origin-top-left"
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
title={trimmed ? `${S.chat.workspace}:${trimmed}` : S.chat.workspaceHint}
|
||||
aria-label={S.chat.workspace}
|
||||
onClick={toggle}
|
||||
className={pillClass}
|
||||
>
|
||||
{/* Folder icon */}
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="ml-0.5 shrink-0 text-gray-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className={`min-w-0 truncate ${trimmed ? "font-mono" : ""}`}>{label}</span>
|
||||
<Chevron open={open} size={12} className="shrink-0 text-gray-400" />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1.5 px-2.5 pb-2.5 pt-2">
|
||||
<div className="rounded-md border border-gray-200 dark:border-gray-800">
|
||||
{/* Current path (editable: Enter/blur commits, Escape discards) + "Use this directory" (closes the menu once selected) */}
|
||||
<div className="flex items-center gap-1.5 border-b border-gray-100 px-1.5 py-1 dark:border-gray-800">
|
||||
<input
|
||||
value={pathDraft}
|
||||
placeholder="…"
|
||||
aria-label={S.chat.workspace}
|
||||
onChange={(e) => setPathDraft(e.target.value)}
|
||||
onBlur={() => void commitPathEdit()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
void commitPathEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
// Discard the edit: only reverts the draft; Escape bubbles up to Dropdown, which closes the menu.
|
||||
setPathDraft(dir?.path ?? "");
|
||||
}
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 font-mono text-xs text-gray-600 focus:border-gray-300 focus:outline-none dark:text-gray-300 dark:focus:border-gray-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dir}
|
||||
onClick={() => {
|
||||
if (!dir) return;
|
||||
onChange(dir.path);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="shrink-0 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
{S.chat.workspaceUseThis}
|
||||
</button>
|
||||
</div>
|
||||
{/* Directory list (excludes hidden directories) */}
|
||||
<ul className="max-h-40 overflow-y-auto py-1">
|
||||
{parentPath !== null && (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadDir(parentPath)}
|
||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-mono text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
↰ {S.chat.workspaceUp}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadDir(entry.path)}
|
||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left font-mono text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="shrink-0 text-gray-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{dir && entries.length === 0 && (
|
||||
<li className="px-2.5 py-1.5 text-xs text-gray-400">{S.chat.workspaceNoSubdirs}</li>
|
||||
)}
|
||||
{loading && <li className="px-2.5 py-1.5 text-xs text-gray-400">{S.common.loading}</li>}
|
||||
{/* Load failure (e.g. the cached starting directory was deleted): provide "retry" to fall back to the home directory, avoiding getting stuck in an error state. */}
|
||||
{error && (
|
||||
<li className="flex items-center justify-between gap-2 px-2.5 py-1.5 text-xs text-red-500">
|
||||
<span className="min-w-0 flex-1 truncate" title={error}>
|
||||
{error}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadDir("")}
|
||||
className="shrink-0 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
{S.common.retry}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
{/* When a directory has been specified, offer a one-click way back to the auto temp directory */}
|
||||
{trimmed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs text-gray-500 underline decoration-gray-300 underline-offset-2 transition-colors duration-150 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
{S.chat.workspaceClear}
|
||||
</button>
|
||||
)}
|
||||
{/* Hint text (bottom of the menu) */}
|
||||
<p className="px-0.5 text-xs leading-5 text-gray-400 dark:text-gray-500">
|
||||
{S.chat.workspaceHint}
|
||||
</p>
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user