feat(ui): a Claude-Desktop-style home for every workspace

/workspace?lane=<id> opens on the workspace's home (WorkspaceHome): a prompt
box whose Start opens a new session in the first free pane slot, Recents (a
live session attaches its pane, any other opens its transcript, nothing
resumes), and Instructions / Memory / Context / Folder. Instructions live in
lanes.instructions, extra folders and uploaded files in the new lane_sources
table (copies under <data dir>/workspace-context/<lane id>/); spawnRun reads
them from the lane's rows and hands Claude --add-dir + an appended system
prompt file, Codex --add-dir + developer_instructions. Memory is read-only.

Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run
records which session the pane holds (a Claude pane follows /clear and
/resume but not a nested claude -p), so the sidebar and notifications open
the right pane (&run=). Settings loses its Projects group; Worktrees now
decides which worktrees Add folder suggests.
This commit is contained in:
2026-10-02 01:10:58 +07:00
parent 2e051e23b6
commit d0535af626
44 changed files with 2515 additions and 466 deletions
+63
View File
@@ -2739,6 +2739,37 @@ export const api = {
* @param patch Subset of lane fields to update.
* @returns `{ lane }` — the updated {@link Lane}.
*/
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
* folders and context files, and whether its tool can take them. */
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
setInstructions: (id: number, instructions: string | null) =>
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
addFolder: (id: number, path: string) =>
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
method: "POST",
body: JSON.stringify({ path }),
}),
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
addFile: (id: number, file: File) =>
request<LaneSourcesView>(`/lanes/${id}/sources/file`, {
method: "POST",
body: file,
headers: {
"Content-Type": "application/octet-stream",
"X-File-Name": encodeURIComponent(file.name),
},
}),
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
* uploaded file's AgentHub copy is deleted. */
removeSource: (id: number, sourceId: number) =>
request<LaneSourcesView>(`/lanes/${id}/sources/${sourceId}`, { method: "DELETE" }),
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/lanes/${id}/memory`),
update: (id: number, patch: Partial<Lane>) =>
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
/**
@@ -3879,6 +3910,38 @@ export interface RunHandle {
tool: LaneTool;
}
/** One extra folder or uploaded context file of a workspace. */
export interface LaneSource {
id: number;
kind: "folder" | "file";
path: string;
name: string;
size: number | null;
added_at: string;
/** The folder or file is gone from disk; it is skipped at spawn. */
missing: boolean;
}
/** Response shape of GET /api/lanes/:id/sources (and of every sources write). */
export interface LaneSourcesView {
instructions: string;
/** The workspace's tool can take folders and instructions (claude, codex). */
supported: boolean;
maxFileBytes: number;
maxInstructions: number;
sources: LaneSource[];
}
/** One read-only memory file from GET /api/lanes/:id/memory. */
export interface LaneMemoryFile {
label: string;
path: string;
size: number;
mtime: string;
truncated: boolean;
content: string;
}
/** Response shape of GET /api/run. */
export interface RunListResponse {
items: RunHandle[];
+4 -17
View File
@@ -1,11 +1,10 @@
/**
* @file projectTreePrefs — the sidebar workspace list's viewer preferences, in
* ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are
* expanded to their sessions (by lane id), whether a repo's untracked
* worktrees are offered, and which
* worktree sources (`claude` = `<repo>/.claude/worktrees/*`, `agenthub` =
* LANES_ROOT) are hidden — globally, or per project root, where a project's own
* list (Settings → Projects) replaces the global one. A `useSyncExternalStore` reader that caches nothing
* expanded to their sessions (by lane id), and — Settings → Worktrees —
* whether a workspace home's Add folder suggests the repo's untracked
* worktrees and which sources of them (`claude` = `<repo>/.claude/worktrees/*`,
* `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing
* but the parsed blob for the current raw string, so the sidebar and Settings
* can never disagree. Nothing here reaches the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -20,15 +19,12 @@ export interface ProjectTreePrefs {
expanded: Record<string, boolean>;
showUntracked: boolean;
hideSources: string[];
/** Per project root: replaces `hideSources` for that project. */
projectHideSources: Record<string, string[]>;
}
const DEFAULTS: ProjectTreePrefs = {
expanded: {},
showUntracked: true,
hideSources: [],
projectHideSources: {},
};
const listeners = new Set<() => void>();
let cache: { raw: string | null; value: ProjectTreePrefs } = { raw: null, value: DEFAULTS };
@@ -51,10 +47,6 @@ export function readProjectTreePrefs(): ProjectTreePrefs {
expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {},
showUntracked: parsed.showUntracked !== false,
hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [],
projectHideSources:
parsed.projectHideSources && typeof parsed.projectHideSources === "object"
? parsed.projectHideSources
: {},
};
} catch {
value = DEFAULTS;
@@ -72,11 +64,6 @@ export function writeProjectTreePrefs(patch: Partial<ProjectTreePrefs>): void {
listeners.forEach((l) => l());
}
/** The sources hidden for one project: its own override, else the global list. */
export function hiddenSourcesFor(prefs: ProjectTreePrefs, root: string | null): string[] {
return (root !== null && prefs.projectHideSources[root]) || prefs.hideSources;
}
function subscribe(l: () => void) {
listeners.add(l);
return () => listeners.delete(l);