6c605849d8
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
210 lines
7.8 KiB
TypeScript
210 lines
7.8 KiB
TypeScript
/**
|
|
* @file ProjectMemoryModal.tsx
|
|
* @description Create a project's memory file — CLAUDE.md or AGENTS.md — in a
|
|
* directory that is not the dashboard's own. Shared by the Claude Code and
|
|
* Codex config pages; the caller supplies its i18n namespace and the create
|
|
* call, so this component knows about neither tool.
|
|
*
|
|
* The directory list is `GET /api/run/cwds` (the dashboard's known projects),
|
|
* offered as suggestions on a plain text input rather than a closed dropdown:
|
|
* a project you have never run through the dashboard is exactly the case this
|
|
* modal exists for, so it must stay typeable. Typed text is a draft: closing
|
|
* asks first, and it is a real dialog (focus trap, Escape).
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { BookOpen, X, Save, AlertCircle } from "lucide-react";
|
|
import { api, type CwdSuggestion } from "../../lib/api";
|
|
import { confirmDiscard } from "../../lib/confirm";
|
|
import { useDialog } from "../../hooks/useDialog";
|
|
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
|
import { PathPickButton } from "../PathPickButton";
|
|
|
|
interface ProjectMemoryModalProps {
|
|
/** i18n namespace of the calling page — "ccConfig" or "codexConfig". */
|
|
ns: string;
|
|
/** The file this will create, e.g. "CLAUDE.md". Shown, never sent. */
|
|
filename: string;
|
|
/** Starting body text, so the user edits a skeleton instead of a blank box. */
|
|
template: string;
|
|
onClose: () => void;
|
|
/** Resolves with the path actually written (the server realpaths the dir). */
|
|
onCreate: (args: { dir: string; content: string }) => Promise<string>;
|
|
}
|
|
|
|
export function ProjectMemoryModal({
|
|
ns,
|
|
filename,
|
|
template,
|
|
onClose,
|
|
onCreate,
|
|
}: ProjectMemoryModalProps) {
|
|
// Every key lives under `projectMemory.*` in BOTH namespaces, so this
|
|
// component never depends on the calling page's other key groups lining up.
|
|
const { t } = useTranslation(ns);
|
|
const listId = useId();
|
|
const [dir, setDir] = useState("");
|
|
const [content, setContent] = useState(template);
|
|
const [suggestions, setSuggestions] = useState<CwdSuggestion[]>([]);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
api.run
|
|
.cwds()
|
|
.then((r) => setSuggestions(r.items))
|
|
// Suggestions are a convenience; the input still works without them.
|
|
.catch(() => undefined);
|
|
}, []);
|
|
|
|
// Typed text is a draft: closing (X, Cancel, backdrop, Escape) asks first,
|
|
// and Escape inside the textarea only leaves the field.
|
|
const dirty = dir.trim() !== "" || content !== template;
|
|
useUnsavedGuard(dirty);
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const requestClose = useCallback(async () => {
|
|
if (dirty && !(await confirmDiscard())) return;
|
|
onClose();
|
|
}, [dirty, onClose]);
|
|
useDialog(true, panelRef, () => {
|
|
const el = document.activeElement;
|
|
if (el instanceof HTMLTextAreaElement) {
|
|
el.blur();
|
|
return;
|
|
}
|
|
void requestClose();
|
|
});
|
|
|
|
const handleCreate = useCallback(async () => {
|
|
const target = dir.trim();
|
|
if (!target) {
|
|
setError(t("projectMemory.dirRequired"));
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
await onCreate({ dir: target, content });
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : "unknown");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [dir, content, onCreate, t]);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
|
|
onClick={() => void requestClose()}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={`${listId}-title`}
|
|
className="relative w-full max-w-3xl max-h-[90vh] rounded-xl border border-border bg-surface-1 flex flex-col"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
|
<BookOpen className="w-4 h-4 text-fg-muted" />
|
|
<span
|
|
id={`${listId}-title`}
|
|
className="text-sm font-medium text-fg-primary flex-1 truncate"
|
|
>
|
|
{t("projectMemory.title", { file: filename })}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => void requestClose()}
|
|
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
|
|
aria-label={t("projectMemory.close")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="overflow-auto p-4 space-y-3">
|
|
<div>
|
|
<label
|
|
htmlFor={`${listId}-dir`}
|
|
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
|
>
|
|
{t("projectMemory.dirLabel")}
|
|
</label>
|
|
<div className="flex gap-1.5">
|
|
<input
|
|
id={`${listId}-dir`}
|
|
list={listId}
|
|
value={dir}
|
|
onChange={(e) => setDir(e.target.value)}
|
|
placeholder="/home/you/projects/my-app"
|
|
spellCheck={false}
|
|
className="w-full bg-surface-2 border border-border rounded-md px-3 py-1.5 text-sm font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50"
|
|
/>
|
|
<PathPickButton start={dir} onPick={setDir} />
|
|
</div>
|
|
<datalist id={listId}>
|
|
{suggestions.map((s) => (
|
|
<option key={s.path} value={s.path}>
|
|
{s.label}
|
|
</option>
|
|
))}
|
|
</datalist>
|
|
<p className="mt-1 text-[10px] text-fg-muted">
|
|
{t("projectMemory.dirHelp", { file: filename })}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor={`${listId}-content`}
|
|
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted mb-1"
|
|
>
|
|
{t("projectMemory.contentLabel")}
|
|
</label>
|
|
<textarea
|
|
id={`${listId}-content`}
|
|
value={content}
|
|
onChange={(e) => setContent(e.target.value)}
|
|
spellCheck={false}
|
|
className="w-full h-64 bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary focus:outline-none focus:border-accent/50 resize-y"
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger inline-flex items-center gap-2"
|
|
>
|
|
<AlertCircle className="w-3.5 h-3.5" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void requestClose()}
|
|
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-border bg-surface-2 hover:bg-surface-3 text-fg-secondary"
|
|
>
|
|
{t("projectMemory.cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleCreate}
|
|
disabled={saving}
|
|
className="text-[12px] font-medium px-3 py-1.5 rounded-md border border-accent/40 bg-accent/15 hover:bg-accent/25 text-accent inline-flex items-center gap-1.5 disabled:opacity-60"
|
|
>
|
|
<Save className="w-3.5 h-3.5" />
|
|
{saving ? t("projectMemory.saving") : t("projectMemory.createButton")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|