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:
@@ -14,6 +14,10 @@ function fakeClient() {
|
||||
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||
|
||||
function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) {
|
||||
if (ev.event === "turn_end") {
|
||||
runStartedAtByChatId.delete(chatId);
|
||||
return;
|
||||
}
|
||||
if (ev.event !== "goal_status") return;
|
||||
if (ev.status === "running" && typeof ev.started_at === "number") {
|
||||
runStartedAtByChatId.set(chatId, ev.started_at);
|
||||
@@ -476,6 +480,69 @@ describe("useNanobotStream", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces matching write_file tool events with live file edit activity", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-edit-events", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-file-edit-events", {
|
||||
event: "message",
|
||||
chat_id: "chat-file-edit-events",
|
||||
text: 'write_file({"path":"foo.txt"})',
|
||||
kind: "tool_hint",
|
||||
tool_events: [{
|
||||
phase: "start",
|
||||
call_id: "call-write",
|
||||
name: "write_file",
|
||||
arguments: { path: "foo.txt", content: "hello\n" },
|
||||
}],
|
||||
});
|
||||
fake.emit("chat-file-edit-events", {
|
||||
event: "file_edit",
|
||||
chat_id: "chat-file-edit-events",
|
||||
edits: [{
|
||||
call_id: "call-write",
|
||||
tool: "write_file",
|
||||
path: "foo.txt",
|
||||
phase: "start",
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
approximate: true,
|
||||
status: "editing",
|
||||
}],
|
||||
});
|
||||
fake.emit("chat-file-edit-events", {
|
||||
event: "message",
|
||||
chat_id: "chat-file-edit-events",
|
||||
text: "",
|
||||
kind: "progress",
|
||||
tool_events: [{
|
||||
phase: "end",
|
||||
call_id: "call-write",
|
||||
name: "write_file",
|
||||
arguments: { path: "foo.txt", content: "hello\n" },
|
||||
result: "ok",
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
traces: [],
|
||||
fileEdits: [{
|
||||
call_id: "call-write",
|
||||
tool: "write_file",
|
||||
path: "foo.txt",
|
||||
status: "editing",
|
||||
}],
|
||||
});
|
||||
expect(result.current.messages[0].toolEvents).toBeUndefined();
|
||||
});
|
||||
|
||||
it("upgrades pending file_edit placeholders when the path arrives", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-edit-pending", EMPTY_MESSAGES), {
|
||||
@@ -591,7 +658,7 @@ describe("useNanobotStream", () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
it("starts a new assistant bubble for deltas after stream_end and activity", async () => {
|
||||
it("keeps interrupted pre-tool text inside activity before the final answer", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@@ -625,7 +692,9 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "I created the files.",
|
||||
content: "",
|
||||
reasoning: "I created the files.",
|
||||
isStreaming: false,
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
@@ -638,6 +707,54 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replace interrupted pre-tool text with final stream_end text", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-stream-end-final", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-stream-end-final", {
|
||||
event: "delta",
|
||||
chat_id: "chat-stream-end-final",
|
||||
text: "I will inspect the project first.",
|
||||
});
|
||||
fake.emit("chat-stream-end-final", {
|
||||
event: "stream_end",
|
||||
chat_id: "chat-stream-end-final",
|
||||
});
|
||||
fake.emit("chat-stream-end-final", {
|
||||
event: "message",
|
||||
chat_id: "chat-stream-end-final",
|
||||
text: 'exec({"cmd":"ls"})',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-stream-end-final", {
|
||||
event: "stream_end",
|
||||
chat_id: "chat-stream-end-final",
|
||||
text: "Done. Open index.html to play.",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "I will inspect the project first.",
|
||||
isStreaming: false,
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
traces: ['exec({"cmd":"ls"})'],
|
||||
});
|
||||
expect(result.current.messages[2]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Done. Open index.html to play.",
|
||||
isStreaming: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a new activity segment for reasoning after file edit activity", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
|
||||
@@ -1374,6 +1491,31 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("clears runStartedAt on turn_end even without idle", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-g", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-g", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-g",
|
||||
status: "running",
|
||||
started_at: 1700,
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBe(1700);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-g", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-g",
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => {
|
||||
const fake = fakeClient();
|
||||
const { result, rerender } = renderHook(
|
||||
|
||||
Reference in New Issue
Block a user