Story 37: Editor Command for Worktrees

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 14:42:41 +00:00
parent 53d9795e31
commit ed5f34b776
5 changed files with 460 additions and 3 deletions

View File

@@ -0,0 +1,47 @@
export interface EditorSettings {
editor_command: string | null;
}
const DEFAULT_API_BASE = "/api";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`;
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const settingsApi = {
getEditorCommand(baseUrl?: string): Promise<EditorSettings> {
return requestJson<EditorSettings>("/settings/editor", {}, baseUrl);
},
setEditorCommand(command: string | null, baseUrl?: string): Promise<EditorSettings> {
return requestJson<EditorSettings>(
"/settings/editor",
{
method: "PUT",
body: JSON.stringify({ editor_command: command }),
},
baseUrl,
);
},
};

View File

@@ -6,6 +6,7 @@ import type {
AgentStatusValue,
} from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents";
import { settingsApi } from "../api/settings";
import type { UpcomingStory } from "../api/workflow";
const { useCallback, useEffect, useRef, useState } = React;
@@ -171,6 +172,72 @@ function DiffCommand({
);
}
function EditorCommand({
worktreePath,
editorCommand,
}: {
worktreePath: string;
editorCommand: string;
}) {
const [copied, setCopied] = useState(false);
const command = `${editorCommand} "${worktreePath}"`;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: select text for manual copy
}
};
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
marginBottom: "6px",
}}
>
<code
style={{
flex: 1,
fontSize: "0.7em",
color: "#8b949e",
background: "#0d1117",
padding: "4px 8px",
borderRadius: "4px",
border: "1px solid #21262d",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{command}
</code>
<button
type="button"
onClick={handleCopy}
style={{
padding: "3px 8px",
borderRadius: "4px",
border: "1px solid #30363d",
background: copied ? "#238636" : "#21262d",
color: copied ? "#fff" : "#8b949e",
cursor: "pointer",
fontSize: "0.7em",
fontWeight: 600,
whiteSpace: "nowrap",
}}
>
{copied ? "Copied" : "Open"}
</button>
</div>
);
}
export function AgentPanel({ stories }: AgentPanelProps) {
const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
@@ -178,10 +245,13 @@ export function AgentPanel({ stories }: AgentPanelProps) {
const [actionError, setActionError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [selectorStory, setSelectorStory] = useState<string | null>(null);
const [editorCommand, setEditorCommand] = useState<string | null>(null);
const [editorInput, setEditorInput] = useState<string>("");
const [editingEditor, setEditingEditor] = useState(false);
const cleanupRefs = useRef<Record<string, () => void>>({});
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Load roster and existing agents on mount
// Load roster, existing agents, and editor preference on mount
useEffect(() => {
agentsApi
.getAgentConfig()
@@ -211,6 +281,14 @@ export function AgentPanel({ stories }: AgentPanelProps) {
})
.catch((err) => console.error("Failed to load agents:", err));
settingsApi
.getEditorCommand()
.then((s) => {
setEditorCommand(s.editor_command);
setEditorInput(s.editor_command ?? "");
})
.catch((err) => console.error("Failed to load editor command:", err));
return () => {
for (const cleanup of Object.values(cleanupRefs.current)) {
cleanup();
@@ -347,6 +425,19 @@ export function AgentPanel({ stories }: AgentPanelProps) {
}
};
const handleSaveEditor = async () => {
try {
const trimmed = editorInput.trim() || null;
const result = await settingsApi.setEditorCommand(trimmed);
setEditorCommand(result.editor_command);
setEditorInput(result.editor_command ?? "");
setEditingEditor(false);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setActionError(`Failed to save editor: ${message}`);
}
};
/** Get all active agent keys for a story. */
const getActiveKeysForStory = (storyId: string): string[] => {
return Object.keys(agents).filter((key) => {
@@ -404,6 +495,81 @@ export function AgentPanel({ stories }: AgentPanelProps) {
)}
</div>
{/* Editor preference */}
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<span style={{ fontSize: "0.75em", color: "#666" }}>Editor:</span>
{editingEditor ? (
<>
<input
type="text"
value={editorInput}
onChange={(e) => setEditorInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveEditor();
if (e.key === "Escape") setEditingEditor(false);
}}
placeholder="zed, code, cursor..."
style={{
fontSize: "0.75em",
background: "#111",
border: "1px solid #444",
borderRadius: "4px",
color: "#ccc",
padding: "2px 6px",
width: "120px",
}}
/>
<button
type="button"
onClick={handleSaveEditor}
style={{
fontSize: "0.7em",
padding: "2px 8px",
borderRadius: "4px",
border: "1px solid #238636",
background: "#238636",
color: "#fff",
cursor: "pointer",
}}
>
Save
</button>
<button
type="button"
onClick={() => setEditingEditor(false)}
style={{
fontSize: "0.7em",
padding: "2px 8px",
borderRadius: "4px",
border: "1px solid #444",
background: "none",
color: "#888",
cursor: "pointer",
}}
>
Cancel
</button>
</>
) : (
<button
type="button"
onClick={() => setEditingEditor(true)}
style={{
fontSize: "0.75em",
background: "none",
border: "1px solid #333",
borderRadius: "4px",
color: editorCommand ? "#aaa" : "#555",
cursor: "pointer",
padding: "2px 8px",
fontFamily: editorCommand ? "monospace" : "inherit",
}}
>
{editorCommand ?? "Set editor..."}
</button>
)}
</div>
{/* Roster badges */}
{roster.length > 0 && (
<div