feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP

- Users page: one Edit user form (display name, email, role, active, a new or
  generated password, workspaces given or taken back, sign out everywhere),
  validated as a whole before anything is written (PATCH /api/users/:id,
  GET /api/users/:id); users.display_name/email added migration-safe.
- Settings → Account for every signed-in user: profile, own password (the
  current one required, this login kept), sign out other devices
  (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie
  only, never a pane token).
- Display names wherever a person is named (sidebar, workspace owner groups,
  Users page and audit log); owner_display_name / reader_display additive.
- Users page: admins add and remove Claude/Codex pool accounts.
- Folder browser: an admin with their own Linux account opens in their home;
  the "Server folder" tick opens the host's (never remembered).
- AI Proxy: "Config 9router" beside Add profile when 9router is installed,
  opened on the dashboard's own host (GET /api/ai-proxies/9router).
- Terminal: a tmux selection reaches the clipboard over plain HTTP too
  (writeClipboard's textarea fallback, focus handed back).
- Chat: a file the session touched outside the workspace previews
  (GET /api/sessions/:id/file).
- install-system: `!requiretty` only when visudo accepts it (sudo-rs on
  Ubuntu 25.10+ rejects the setting).
- openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 17:10:29 +07:00
parent 9c573f1e3c
commit 0c2f49876d
74 changed files with 5347 additions and 392 deletions
@@ -951,9 +951,16 @@ function RunningStep({
}
/** Under a turn that ended: how long it took and the output it made. */
/** An option's label carries the model's " (Recommended)" marker into the
* answer the CLI records; the user's pick reads without it (every picked
* label of a multi-select answer too). */
function stripRecommended(answer: string | undefined): string | undefined {
return answer?.replace(/\s*\(recommended\)/gi, "").trim();
}
/** An AskUserQuestion: each question with the answer the user picked or
* typed (the result's `answers`), "declined" when they refused it, no answer
* yet while the CLI still asks. */
* typed (the result's `answers`, minus the " (Recommended)" marker),
* "declined" when they refused it, no answer yet while the CLI still asks. */
function AskCard({
block,
result,
@@ -972,7 +979,7 @@ function AskCard({
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
>
{questions.map((q, i) => {
const answer = result?.answers?.[q];
const answer = stripRecommended(result?.answers?.[q]);
return (
<div key={i}>
<div className="flex items-start gap-1.5 text-fg-secondary">
@@ -2,7 +2,7 @@
* @file MessageList.ask.test.tsx
* @description An AskUserQuestion call is not folded into the collapsed work
* line: the chat shows it as its own card — each question with the answer the
* user picked or typed, "declined" when they refused it, nothing yet while the
* user picked or typed (without the option's " (Recommended)" marker), "declined" when they refused it, nothing yet while the
* CLI still waits.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -67,6 +67,25 @@ describe("an AskUserQuestion card", () => {
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
});
it("drops the (Recommended) marker from a picked answer", () => {
render(
<MessageList
loading={false}
messages={[
user("go"),
ask("q1", ["Commit?", "Tools?"]),
result("q1", {
answers: { "Commit?": "Yes (Recommended)", "Tools?": "Lint (Recommended), Test" },
}),
]}
/>
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Yes")).toBeTruthy();
expect(within(card).getByText("Lint, Test")).toBeTruthy();
expect(within(card).queryByText(/Recommended/)).toBeNull();
});
it("says declined when refused, and shows no answer while still asked", () => {
const { rerender } = render(
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />