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:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
+146
View File
@@ -258,6 +258,152 @@ describe("ThreadMessages", () => {
expect(screen.getByText("final answer")).toBeInTheDocument();
});
it("keeps late activity above the live assistant answer while streaming", () => {
const messages: UIMessage[] = [
{
id: "t0",
role: "tool",
kind: "trace",
content: "Thinking",
traces: ["Thinking"],
activitySegmentId: "seg-live",
createdAt: 1,
},
{
id: "a1",
role: "assistant",
content: "partial answer",
isStreaming: true,
createdAt: 2,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "Reading api.github.com/repos/NousResearch/hermes-agent",
traces: ["Reading api.github.com/repos/NousResearch/hermes-agent"],
activitySegmentId: "seg-live",
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"t0",
"t1",
]);
expect(units[1]).toMatchObject({
type: "single",
message: {
id: "a1",
content: "partial answer",
},
});
render(<ThreadMessages messages={messages} isStreaming />);
const activity = screen.getByRole("button", { name: /working/i });
const answer = screen.getByText("partial answer");
expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("keeps late activity above a completed assistant answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: "checking weather",
activitySegmentId: "seg-late",
createdAt: 1,
},
{
id: "a1",
role: "assistant",
content: "Hong Kong is hot today.",
latencyMs: 161_000,
createdAt: 2,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "Reading hko.gov.hk/en/wxinfo/currwx/current.htm",
traces: ["Reading hko.gov.hk/en/wxinfo/currwx/current.htm"],
activitySegmentId: "seg-late",
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1]).toMatchObject({
type: "single",
message: {
id: "a1",
content: "Hong Kong is hot today.",
},
});
render(<ThreadMessages messages={messages} isStreaming={false} />);
const activity = screen.getByText("Thought for 2m 41s");
const answer = screen.getByText("Hong Kong is hot today.");
expect(activity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getAllByText(/thought/i)).toHaveLength(1);
});
it("renders interrupted pre-tool text as activity before the final answer", () => {
const messages: UIMessage[] = [
{
id: "prelude",
role: "assistant",
content: "",
reasoning: "I will inspect first.",
isStreaming: false,
activitySegmentId: "seg-1",
createdAt: 1,
},
{
id: "tool",
role: "tool",
kind: "trace",
content: 'exec({"cmd":"ls"})',
traces: ['exec({"cmd":"ls"})'],
activitySegmentId: "seg-1",
createdAt: 2,
},
{
id: "final",
role: "assistant",
content: "Done. Open index.html to play.",
createdAt: 3,
},
];
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(2);
expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
"prelude",
"tool",
]);
expect(units[1]).toMatchObject({
type: "single",
message: {
id: "final",
content: "Done. Open index.html to play.",
},
});
});
it("passes assistant turn latency to the preceding completed activity cluster", () => {
const messages: UIMessage[] = [
{