feat(webui): add project workspaces and access controls (#4007)
* feat(webui): add project workspaces and access controls * feat(webui): add project workspaces and access controls * refactor(tools): centralize workspace access resolution * refactor(webui): remove unused workspace host state * fix(webui): hide estimated file edit label * fix(webui): clarify file edit deletion feedback * fix(webui): label deleted file activity * fix(webui): flatten file edit activity rows * fix(core): remove path-only patch deletion * fix(core): keep apply patch non-destructive * refactor(webui): trim workspace host plumbing * fix(tools): register exec with tools config
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
OutboundMcpPresetMention,
|
||||
OutboundMedia,
|
||||
GoalStateWsPayload,
|
||||
WorkspaceScopePayload,
|
||||
} from "./types";
|
||||
|
||||
/** WebSocket readyState constants, referenced by value to stay portable
|
||||
@@ -57,22 +58,25 @@ type EventHandler = (ev: InboundEvent) => void;
|
||||
type StatusHandler = (status: ConnectionStatus) => void;
|
||||
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
|
||||
type SessionUpdateScope = "metadata" | "thread" | string;
|
||||
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
|
||||
type SessionUpdateHandler = (
|
||||
chatId: string,
|
||||
scope?: SessionUpdateScope,
|
||||
workspaceScope?: WorkspaceScopePayload,
|
||||
) => void;
|
||||
type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
|
||||
|
||||
/** Structured connection-level errors surfaced to the UI.
|
||||
/** Structured errors surfaced to the UI.
|
||||
*
|
||||
* These are *not* InboundEvent errors from the server application layer —
|
||||
* those arrive as ``{event: "error"}`` messages via ``onChat``. These are
|
||||
* transport-level or protocol-level faults the UI should make visible so
|
||||
* the user understands *why* their action failed (as opposed to silently
|
||||
* reconnecting under the hood).
|
||||
* Most entries are transport-level or protocol-level faults. Workspace scope
|
||||
* rejections are server application errors promoted here because they affect
|
||||
* controls outside the message stream and must be visible immediately.
|
||||
*/
|
||||
export type StreamError =
|
||||
/** Server rejected the inbound frame as too large (WS close code 1009).
|
||||
* Typically means the user attached images whose base64 size exceeded
|
||||
* ``maxMessageBytes`` on the server. */
|
||||
| { kind: "message_too_big" };
|
||||
| { kind: "message_too_big" }
|
||||
| { kind: "workspace_scope_rejected"; reason?: string; chatId?: string };
|
||||
|
||||
type ErrorHandler = (error: StreamError) => void;
|
||||
|
||||
@@ -206,6 +210,13 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
|
||||
if (ev.event === "turn_end") {
|
||||
if (this.runStartedAtByChatId.has(chatId)) {
|
||||
this.runStartedAtByChatId.delete(chatId);
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.event !== "goal_status") return;
|
||||
if (ev.status === "running" && typeof ev.started_at === "number") {
|
||||
const previous = this.runStartedAtByChatId.get(chatId);
|
||||
@@ -281,7 +292,7 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
/** Ask the server to provision a new chat_id; resolves with the assigned id. */
|
||||
newChat(timeoutMs: number = 5_000): Promise<string> {
|
||||
newChat(timeoutMs: number = 5_000, workspaceScope?: WorkspaceScopePayload | null): Promise<string> {
|
||||
if (this.pendingNewChat) {
|
||||
return Promise.reject(new Error("newChat already in flight"));
|
||||
}
|
||||
@@ -291,7 +302,10 @@ export class NanobotClient {
|
||||
reject(new Error("newChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer };
|
||||
this.queueSend({ type: "new_chat" });
|
||||
this.queueSend({
|
||||
type: "new_chat",
|
||||
...(workspaceScope ? { workspace_scope: workspaceScope } : {}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -310,6 +324,7 @@ export class NanobotClient {
|
||||
imageGeneration?: OutboundImageGeneration;
|
||||
cliApps?: OutboundCliAppMention[];
|
||||
mcpPresets?: OutboundMcpPresetMention[];
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
},
|
||||
): void {
|
||||
this.knownChats.add(chatId);
|
||||
@@ -321,11 +336,21 @@ export class NanobotClient {
|
||||
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
|
||||
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
|
||||
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
|
||||
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
|
||||
webui: true,
|
||||
};
|
||||
this.queueSend(frame);
|
||||
}
|
||||
|
||||
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
||||
this.knownChats.add(chatId);
|
||||
this.queueSend({
|
||||
type: "set_workspace_scope",
|
||||
chat_id: chatId,
|
||||
workspace_scope: workspaceScope,
|
||||
});
|
||||
}
|
||||
|
||||
// -- internals ---------------------------------------------------------
|
||||
|
||||
private setStatus(status: ConnectionStatus): void {
|
||||
@@ -388,10 +413,23 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
if (parsed.event === "session_updated") {
|
||||
this.emitSessionUpdate(parsed.chat_id, parsed.scope);
|
||||
this.emitSessionUpdate(parsed.chat_id, parsed.scope, parsed.workspace_scope);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") {
|
||||
this.emitError({
|
||||
kind: "workspace_scope_rejected",
|
||||
reason: parsed.reason,
|
||||
chatId: parsed.chat_id,
|
||||
});
|
||||
if (this.pendingNewChat) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
this.pendingNewChat.reject(new Error(`workspace_scope_rejected:${parsed.reason || ""}`));
|
||||
this.pendingNewChat = null;
|
||||
}
|
||||
}
|
||||
|
||||
const chatId = (parsed as { chat_id?: string }).chat_id;
|
||||
if (chatId) {
|
||||
this.recordGoalStatusForRunStrip(chatId, parsed);
|
||||
@@ -406,9 +444,13 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private emitSessionUpdate(chatId: string, scope?: SessionUpdateScope): void {
|
||||
private emitSessionUpdate(
|
||||
chatId: string,
|
||||
scope?: SessionUpdateScope,
|
||||
workspaceScope?: WorkspaceScopePayload,
|
||||
): void {
|
||||
for (const handler of this.sessionUpdateHandlers) {
|
||||
handler(chatId, scope);
|
||||
handler(chatId, scope, workspaceScope);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user