diff --git a/CHANGELOG.md b/CHANGELOG.md index 3664068..763daab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. ### Added -- **A redesigned dashboard that gets you back to work faster.** The dashboard now opens with a **You** zone — *Focus today* (your assigned work, priority-first) and *Pick up where you left off* (your most recently touched issues) — followed by a **Workspace & agents** zone (agent activity, attention, handoffs, stalled, pipeline). The "Suggestions" strip (unassigned + stalled) moved out of your personal area down into the workspace zone, where it belongs. +- **A redesigned dashboard cockpit — less scrolling, no gaps.** The dashboard is now a two-column cockpit: a wide work column on the left (*Focus today*, *Pick up where you left off*, and a compact **Pipeline** card) beside an always-visible rail on the right (agent activity, attention, standup, what's new) — instead of one long stacked column. The "Suggestions" strip (unassigned + stalled) stays in the work column, below Pipeline. - **Richer issue cards on the dashboard.** Focus and Pick-up cards now show who's on it (assignee + agent avatars with a presence dot), sub-issue progress, a live agent-run status (running / waiting / stalled), project / label / due / SLA context, and a one-line description when the title is short — each shown only when it applies. Cards size to their own content, so the empty gaps between sparse cards are gone. - **Set a per-issue SLA target — and let the Coach react to breaches.** Each issue's detail rail now has an **SLA target** field: pick a preset (1 hour, 4 hours, 1 day, 1 week…) or a custom number of minutes, or clear it. When **Enforce per-issue SLA** is on (Settings → Workspace → Agent SLA), an issue that ages past its target raises an SLA breach — which posts a Coach diagnostic comment and shows in your activity feed. Issues with no target are never breached, so turning enforcement on is safe. - **See your Coach agent's health at a glance.** Settings → Workspace → AI now shows whether the Coach is *armed*, whether it can reach a model, whether the Coach agent exists, which events trigger it (issue stalled / missed ack / SLA breach — and which are turned off), and when it last fired. No more guessing why it is or isn't commenting. @@ -24,6 +24,7 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. - **Cap self-service workspace creation.** Operators can set `MAX_WORKSPACES_PER_USER` to limit how many workspaces a single (non-admin) user can create; the default is unlimited, so nothing changes unless you set it. - **Approve or reject a paused agent run right where you see it.** When an agent pauses for permission (a Codex/Hermes run flagged a command or file change), the run row in Mission Control's Live tab now shows **Approve** / **Reject** inline — no detour to the Command Center. - **See a plan's wall-clock burn.** The plan cockpit's budget meter now shows elapsed time vs. the wall-time cap alongside cost, so a plan approaching its time budget is visible at a glance (matching the goal cockpit). +- **Set the engagement mode right when you assign an agent in Quick Create.** Type `/assign @handle` in the new-issue overlay and a small Execute / Research / Review / Discuss picker appears next to the assignee chip — pick one to override the workspace's default for that dispatch, or leave it alone to keep using the default. ### Changed @@ -43,6 +44,7 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. - **Approving or stopping an agent run tells you if it didn't go through.** A failed Approve or Stop now surfaces an error instead of showing success while the run stays blocked at the runtime. - **Goals and Plans tell a load error apart from an empty list.** A network hiccup now shows "couldn't load … Retry" instead of "No goals yet" / "Goal not found — may have been abandoned", which looked like your work had been deleted. - **A freshly registered runtime is no longer a dead-end.** You can open a just-registered local daemon's settings — run its self-test, add credentials, bind a repo — straight from the global **Runtimes** page, without waiting for an agent to use it first. +- **Research and Review dispatches can actually read the repo.** A Hermes runtime with no per-mode tool profile configured used to hand a Research or Review run zero local tools — even though the mode's own contract promises "read, search, run read-only tools." It now gets the read-oriented subset (filesystem + git, never terminal) of whatever the runtime declared, matching what was already documented. ### Changed diff --git a/DEVLOG.md b/DEVLOG.md index 381243a..f51f038 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,72 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-07 — Dashboard Cockpit layout, RESEARCH/REVIEW tool-access default, Quick Create mode picker + +Three independent pieces from one session, all typecheck + lint clean. + +- **Dashboard: Cockpit layout (variant A of a 3-mockup comparison).** Replaced + the single stacked `max-w-6xl` column with a two-column cockpit at + `max-w-[100rem]`: primary work column (`lg:col-span-8` — Focus today, Pick up + where you left off, a new **Pipeline** card, Suggestions) beside an always- + visible rail (`lg:col-span-4` — the existing customizable widget stack, + forced to a single column). `DashboardStack` gained a `columns?: 1 | 2` prop + (default 2, back-compat — it's only used on this one page) that drops the + `lg:grid-cols-2` track and hides the half/full resize affordances when `1`, + since they'd have no visible effect in a forced single column. The old + "By status" link-list became `PipelineCard` — the same `statusRows`/ + `statusMax` data as horizontal proportional bars in a card instead of a + 2-column list, matching the mockup and removing the ragged-bottom gap next + to Focus/Pick-up. Default widget order in the rail now leads with + agent-activity/agent-attention/standup/whats-new/quick-notes (existing + per-user saved order still overrides via `orderWidgets()` — no regression + for anyone who already customized). Verified end-to-end against the local + `dev:local` stack with real seeded + assigned issues (3-col Focus grid, + populated agent-activity rail, real Standup counts) via a scripted + Playwright login — not just typecheck. +- **RESEARCH/REVIEW tool-access default (`src/lib/runtime-tools.ts`).** Traced + a Hermes RESEARCH-mode run's "no filesystem/git/terminal, web search not + configured" complaint to two separate root causes: web search isn't a Forge + concept at all (not in `RUNTIME_TOOL_CAPABILITIES`, docs explicitly say it + stays available regardless of mode — that's a Hermes-gateway-config + question, not a Forge bug); but `runtimeModeToolCapabilities()` returned + `[]` for **any** non-EXECUTE mode with no explicit `modeToolProfiles` + override, contradicting `docs/agents/engagement-modes.md`'s own promise + ("Research: Read, search, run read-only tools"). Fixed: RESEARCH/REVIEW now + default to the read-oriented subset (`filesystem`, `git` — never + `terminal`) of whatever the runtime actually declared; EXECUTE and an + explicit `modeToolProfiles` override are unchanged; DISCUSS still gets + nothing. The Runtime settings page's per-mode checkbox matrix + (`settings/runtimes/page.tsx`) reads this function directly, so operators + now see the corrected default there too. New `tests/unit/runtime-tools.test.ts` + (7 cases). `modeToolPolicyEnforced` still defaults off — this only fixes + what's *advertised*/prompted; hard host enforcement stays opt-in per Runtime. +- **Quick Create: engagement mode picker on `/assign`.** The new-issue global + overlay already assigned agents via `/assign @handle`, but had no way to set + the run's engagement mode — the server hardcoded `explicit: null`, so it + always fell back to the workspace's assignment default. `SlashCommand`'s + `assign` variant gained an optional `mode?: EngagementMode` (UI-only — never + parsed from typed text, matching the existing "mode is set via UI, not + slash-syntax" split with the @-mention grammar). `quick-create.tsx` renders a + compact 4-button mode picker (reusing `EngagementModeGlyph`/`MODE_ORDER` from + the issue-detail `AgentPickerModal`) right next to the committed assign + badge, unset by default (no button pre-highlighted — honest about "no + override yet" rather than guessing the resolved default). `issue.ts`'s + `applySlashCommandsToIssue` "assign" case now passes `cmd.mode ?? null` as + `explicit` to `resolveEngagementMode()` instead of the hardcoded `null`. No + Prisma migration — `Issue` never had an `engagementMode` column by design + (mode lives on the `AgentRun`); this just stops discarding an already-storable + value. Verified via a scripted Playwright run: typed `/assign victor`, + confirmed the picker appears unselected, clicked "Research," confirmed + `aria-checked` flips correctly and only on that button. + +Housekeeping: this worktree's `node_modules` was missing `server-only`, +failing 21 unrelated `tests/unit/*.test.ts` files (none touched by this +session) with `Cannot find module 'server-only'`; `pnpm install` didn't +resolve it. Pre-existing environmental gap, not a regression — all tests in +files actually touched here pass (`runtime-tools`, `slash-commands`, +`chat-slash-command-gating` — 36/36). + ## 2026-07-06 — Agent-runtime audit: Phase 3 UI consistency (P3.2) Orchestration-surface polish on `worktree-audit-fixes`. Six sub-items, all diff --git a/src/app/(app)/w/[slug]/dashboard/page.tsx b/src/app/(app)/w/[slug]/dashboard/page.tsx index d2889b9..3d79b8b 100644 --- a/src/app/(app)/w/[slug]/dashboard/page.tsx +++ b/src/app/(app)/w/[slug]/dashboard/page.tsx @@ -149,10 +149,11 @@ export default function DashboardPage() { [setPrefsMut], ); - // Zone-2 (Workspace & agents) widget stack. Agents lead the default - // order; the rest stays user-reorderable/hideable via Customize. The - // "Pick up where you left off" tile is gone — it's now a first-class - // Zone-1 section rendered as rich cards. + // Rail widget stack (the ambient "workspace & agents" column beside your + // work). Agents/standup/news lead the default order since they're the + // highest-signal glance items; the rest stays user-reorderable/hideable + // via Customize. The "Pick up where you left off" tile is gone — it's + // now a first-class primary-column section rendered as rich cards. const widgets: DashboardWidget[] = useMemo( () => [ { @@ -167,6 +168,21 @@ export default function DashboardPage() { defaultWidth: "half", node: , }, + { id: "standup", title: "Standup", defaultWidth: "half", node: }, + { + id: "whats-new", + title: "What's new", + defaultWidth: "half", + node: ( + + ), + }, + { + id: "quick-notes", + title: "Notes & journal", + defaultWidth: "full", + node: , + }, { id: "today", title: "Today", @@ -174,28 +190,13 @@ export default function DashboardPage() { node: , }, { id: "pulse", title: "Pulse", defaultWidth: "half", node: }, + { id: "ideas", title: "Ideas", defaultWidth: "half", node: }, { id: "workspace-activity", title: "Workspace activity", defaultWidth: "full", node: , }, - { id: "standup", title: "Standup", defaultWidth: "half", node: }, - { id: "ideas", title: "Ideas", defaultWidth: "half", node: }, - { - id: "quick-notes", - title: "Notes & journal", - defaultWidth: "full", - node: , - }, - { - id: "whats-new", - title: "What's new", - defaultWidth: "half", - node: ( - - ), - }, ], [slug, workspaceKey, account?.changelogSeenAt], ); @@ -258,7 +259,7 @@ export default function DashboardPage() { {/* Ambient background now lives once in the app shell
(.forge-page-bg, driven by the per-user data-bg pref). */}
-
+
- {/* ── Zone 1 · YOU ───────────────────────────────────────── - Focus (assigned, priority-first) + Pick-up (recent). Both - render rich auto-height cards. If there's no personal work at - all, Suggestions takes the slot as the primary handoff. */} - {focusCards.length === 0 && resumeCards.length === 0 && !myWorkLoading ? ( - - ) : ( -
- {(focusCards.length > 0 || myWorkLoading) && ( -
- - -
- )} - {resumeCards.length > 0 && ( -
- - -
+ {/* ── Cockpit body: a wide work column + a compact ambient rail. + Primary (8/12) is "what should I do" — Focus, Pick-up, + Pipeline. The rail (4/12) is "what's going on around me" — + agents, standup, news — always visible beside your work + instead of scrolled past below it. */} +
+
+ {/* Focus (assigned, priority-first) + Pick-up (recent). Both + render rich auto-height cards. If there's no personal work + at all, Suggestions takes the slot as the primary handoff. */} + {focusCards.length === 0 && resumeCards.length === 0 && !myWorkLoading ? ( + + ) : ( + <> + {(focusCards.length > 0 || myWorkLoading) && ( +
+ + +
+ )} + {resumeCards.length > 0 && ( +
+ + +
+ )} + )} -
- )} - - {/* ── Zone 2 · WORKSPACE & AGENTS ────────────────────────── - Agents, attention, and the rest of the customizable stack; - handoffs & stalled (the old under-Focus Suggestions, demoted - here); and a by-status pipeline. */} -
- - + - + +
-
- -
    - {statusRows.map(({ status, count }) => ( -
  • - - - {status.name} -
    -
    -
    - - -
  • - ))} -
-
+
+ + +
@@ -491,6 +465,53 @@ function WorkCardGrid({ ); } +/** Active-work pipeline as proportional bars in a single card — replaces + * the old two-column status link-list with something that reads at a + * glance and doesn't leave a ragged column bottom next to Focus/Pick-up. */ +function PipelineCard({ + statusRows, + statusMax, + href, +}: { + statusRows: { status: { id: string; name: string; color: string }; count: number }[]; + statusMax: number; + href: string; +}) { + if (statusRows.length === 0) return null; + return ( +
+ +
    + {statusRows.map(({ status, count }) => ( +
  • + + + {status.name} + +
    +
    +
    + + +
  • + ))} +
+
+ ); +} + /** Labeled hairline divider that opens a dashboard zone. */ function ZoneDivider({ label }: { label: string }) { return ( diff --git a/src/components/dashboard/dashboard-stack.tsx b/src/components/dashboard/dashboard-stack.tsx index b7607ed..0b031c6 100644 --- a/src/components/dashboard/dashboard-stack.tsx +++ b/src/components/dashboard/dashboard-stack.tsx @@ -86,11 +86,16 @@ export function DashboardStack({ layout, editing, onChange, + columns = 2, }: { widgets: DashboardWidget[]; layout: DashboardLayout; editing: boolean; onChange: (next: DashboardLayout) => void; + /** 1 forces a single-column stack (e.g. a narrow rail) — half/full width + * then both render at the rail's full width, so the resize control is + * hidden rather than offered with no visible effect. */ + columns?: 1 | 2; }) { const ordered = orderWidgets(widgets, layout.order); const orderedIds = ordered.map((w) => w.id); @@ -189,13 +194,20 @@ export function DashboardStack({ return (
-
+
{visible.map((w, index) => ( handleDrag(w.id, e)} @@ -234,6 +246,7 @@ function DashboardTile({ widget, width, editing, + columns, isFirst, isLast, onDrag, @@ -245,6 +258,7 @@ function DashboardTile({ widget: DashboardWidget; width: WidgetWidth; editing: boolean; + columns: 1 | 2; isFirst: boolean; isLast: boolean; onDrag: (e: PointerEvent | MouseEvent | TouchEvent) => void; @@ -312,7 +326,7 @@ function DashboardTile({ // editing (in edit mode the control strip keeps it non-empty so it // stays hideable). !editing && "empty:hidden", - isFull && "lg:col-span-2", + isFull && columns === 2 && "lg:col-span-2", editing && "rounded-lg ring-1 ring-dashed ring-ember/40", dragging && "shadow-[0_18px_44px_rgba(0,0,0,0.20)]", )} @@ -324,15 +338,17 @@ function DashboardTile({ {widget.title}
- + {columns === 2 && ( + + )} + ); + })} + + ); +} + function CommittedBadgeContent({ command, agentByHandle, diff --git a/src/lib/runtime-tools.ts b/src/lib/runtime-tools.ts index f6ce4cf..225b933 100644 --- a/src/lib/runtime-tools.ts +++ b/src/lib/runtime-tools.ts @@ -87,6 +87,14 @@ export function runtimeHostEnforcesModeToolPolicy(config: unknown): boolean { return c.modeToolPolicyEnforced === true || c.hostEnforcedToolPolicy === true; } +// Default tools a Research/Review run gets when the operator hasn't set an +// explicit `modeToolProfiles` override: read/inspect-oriented capabilities +// only. `terminal` is excluded even when declared — arbitrary command +// execution isn't "read-only" per the documented mode contract +// (docs/agents/engagement-modes.md: Research = "Read, search, run read-only +// tools"). +const READ_ORIENTED_CAPABILITIES: readonly RuntimeToolCapability[] = ["filesystem", "git"]; + export function runtimeModeToolCapabilities( config: unknown, mode: RuntimeEngagementMode, @@ -103,8 +111,12 @@ export function runtimeModeToolCapabilities( return RUNTIME_TOOL_CAPABILITIES.filter((tool) => tools.has(tool)); } - if (mode !== "EXECUTE") return []; - return declaredRuntimeToolCapabilities(config); + const declared = declaredRuntimeToolCapabilities(config); + if (mode === "EXECUTE") return declared; + if (mode === "DISCUSS") return []; + // RESEARCH / REVIEW: never grant more than the runtime actually declared, + // and never terminal — just the read-oriented subset of what's available. + return declared.filter((tool) => READ_ORIENTED_CAPABILITIES.includes(tool)); } export function runtimeToolGrantStrategy( diff --git a/src/lib/slash-commands.ts b/src/lib/slash-commands.ts index 6bc9f2a..c971975 100644 --- a/src/lib/slash-commands.ts +++ b/src/lib/slash-commands.ts @@ -25,7 +25,10 @@ * Recognized commands (case-insensitive on keyword; arg semantics * documented per command): * - * /assign @handle — set assignedAgent by profileKey + * /assign @handle — set assignedAgent by profileKey. `mode` is + * UI-only (set via the assign badge's mode + * picker, not typed) — an unset `mode` falls + * back to the surface's resolved default. * /due — set dueDate; "today" / "tomorrow" / * "in 3 days" / "in 1 week" / "next Monday" * / "2026-05-15" / "May 15" @@ -41,9 +44,10 @@ * keeps the body "what would I have typed without the command lines" * predictable. */ +import type { EngagementMode } from "@prisma/client"; export type SlashCommand = - | { kind: "assign"; handle: string } + | { kind: "assign"; handle: string; mode?: EngagementMode } | { kind: "due"; date: Date } | { kind: "label"; name: string } | { diff --git a/src/server/routers/issue.ts b/src/server/routers/issue.ts index 1fdab36..1a2aae6 100644 --- a/src/server/routers/issue.ts +++ b/src/server/routers/issue.ts @@ -130,7 +130,7 @@ async function applySlashCommandsToIssue(opts: { }); const resolved = resolveEngagementMode({ surface: "assignment", - explicit: null, + explicit: cmd.mode ?? null, workspace: { ...ws, assignmentAgentEngagementMode: agent.engagementMode }, }); await recordChange(tx, { diff --git a/tests/unit/runtime-tools.test.ts b/tests/unit/runtime-tools.test.ts new file mode 100644 index 0000000..f1181b4 --- /dev/null +++ b/tests/unit/runtime-tools.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { runtimeModeToolCapabilities } from "@/lib/runtime-tools"; + +describe("runtimeModeToolCapabilities", () => { + const declaresAll = { toolCapabilities: ["terminal", "filesystem", "git"] }; + + it("gives EXECUTE everything the runtime declared", () => { + expect(runtimeModeToolCapabilities(declaresAll, "EXECUTE")).toEqual([ + "terminal", + "filesystem", + "git", + ]); + }); + + it("gives RESEARCH the read-oriented subset by default, never terminal", () => { + expect(runtimeModeToolCapabilities(declaresAll, "RESEARCH")).toEqual(["filesystem", "git"]); + }); + + it("gives REVIEW the same read-oriented subset by default", () => { + expect(runtimeModeToolCapabilities(declaresAll, "REVIEW")).toEqual(["filesystem", "git"]); + }); + + it("gives DISCUSS nothing by default", () => { + expect(runtimeModeToolCapabilities(declaresAll, "DISCUSS")).toEqual([]); + }); + + it("never grants a tool the runtime didn't declare, even for RESEARCH", () => { + expect(runtimeModeToolCapabilities({ toolCapabilities: ["terminal"] }, "RESEARCH")).toEqual([]); + expect(runtimeModeToolCapabilities({ toolCapabilities: ["git"] }, "RESEARCH")).toEqual(["git"]); + }); + + it("respects an explicit modeToolProfiles override over the default", () => { + const config = { + ...declaresAll, + modeToolProfiles: { RESEARCH: ["terminal"] }, + }; + expect(runtimeModeToolCapabilities(config, "RESEARCH")).toEqual(["terminal"]); + // Other modes still fall back to the computed default. + expect(runtimeModeToolCapabilities(config, "REVIEW")).toEqual(["filesystem", "git"]); + }); + + it("an empty explicit override means literally zero tools, not fallback", () => { + const config = { ...declaresAll, modeToolProfiles: { RESEARCH: [] } }; + expect(runtimeModeToolCapabilities(config, "RESEARCH")).toEqual([]); + }); +});