Compare commits

..

3 Commits

Author SHA1 Message Date
Naiyuan Qing
b48f4d0f97 refactor(web): unify react-grab opt-in var to VITE_REACT_GRAB
Use the same env var name as the desktop renderer so one variable name
controls both apps. The desktop renderer is bundled by Vite, which only
exposes VITE_-prefixed vars to client code, so the shared name must carry
the VITE_ prefix; web reads it server-side where the name is unconstrained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:19:08 +08:00
Naiyuan Qing
dee81503cb feat(desktop): add opt-in react-grab dev element inspector
Mirrors the web wiring for the Electron renderer: injects the react-grab
overlay (hold ⌘C / Ctrl+C + click to copy an element's source path +
component stack) only when VITE_REACT_GRAB is set in a local, gitignored
apps/desktop/.env.development.local. Guarded by import.meta.env.DEV so the
branch is tree-shaken out of production builds; never activates for other
developers. No CSP/sandbox blocks the unpkg script (webSecurity is off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:11:00 +08:00
Naiyuan Qing
ec15501adf feat(web): add opt-in react-grab dev element inspector
Loads the react-grab overlay (hold ⌘C / Ctrl+C + click to copy an
element's source path + component stack) only when REACT_GRAB is set in
a local, gitignored apps/web/.env.local. Both the NODE_ENV and REACT_GRAB
guards are evaluated server-side in the root layout, so the <Script> tag
is omitted from the HTML for anyone who hasn't opted in — no effect on
other developers or production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 08:55:02 +08:00
7 changed files with 97 additions and 220 deletions

View File

@@ -11,11 +11,6 @@ if (typeof globalThis.ResizeObserver === "undefined") {
} as unknown as typeof ResizeObserver;
}
// jsdom doesn't implement elementFromPoint; input-otp uses it internally.
if (typeof document.elementFromPoint !== "function") {
document.elementFromPoint = () => null;
}
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
// Create a proper localStorage mock if methods are missing.
if (

View File

@@ -198,23 +198,6 @@ describe("LoginPage", () => {
expect(screen.getByText(/test@example.com/)).toBeInTheDocument();
});
it("autofocuses the OTP input when the code step opens", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
renderWithI18n(<LoginPage onSuccess={onSuccess} />);
const user = userEvent.setup();
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.click(screen.getByRole("button", { name: /continue/i }));
await waitFor(() => {
expect(screen.getByText(/check your email/i)).toBeInTheDocument();
});
// The OTP field should be focused on mount so the user can type the code
// without clicking it first — important when repeatedly switching accounts.
expect(getOTPInput()).toHaveFocus();
});
it("shows error when sendCode fails", async () => {
mockSendCode.mockRejectedValueOnce(new Error("Rate limited"));
renderWithI18n(<LoginPage onSuccess={onSuccess} />);

View File

@@ -349,7 +349,6 @@ export function LoginPage({
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<InputOTP
autoFocus
maxLength={6}
value={code}
onChange={(value) => {

View File

@@ -4,7 +4,6 @@ import (
"context"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
@@ -26,8 +25,6 @@ Your responsibilities, in order:
1. **Read the issue** (title, description, latest comments, acceptance
criteria) and decide which squad member is best suited to do the work.
Match the task to each member's listed **skills** and role in the Squad
Roster below — prefer the member whose skills cover the work.
2. **Delegate by @mention.** Post a single comment on this issue that
@mentions the chosen member(s) and tells them what to do.
- **Be terse.** Every Multica agent already has full context of the
@@ -181,9 +178,7 @@ func renderMemberRow(ctx context.Context, q *db.Queries, m db.SquadMember) strin
if ag.ArchivedAt.Valid {
return ""
}
// Agents carry skills; surfacing them lets the leader delegate by
// capability instead of guessing from the free-text role label.
return formatRosterRow(ag.Name, "agent", role, agentSkillsRosterSegment(ctx, q, m.MemberID), formatMention(ag.Name, "agent", id))
return formatRosterRow(ag.Name, "agent", role, formatMention(ag.Name, "agent", id))
case "member":
user, err := q.GetUser(ctx, m.MemberID)
if err != nil {
@@ -191,38 +186,14 @@ func renderMemberRow(ctx context.Context, q *db.Queries, m db.SquadMember) strin
}
// Mention syntax for humans uses the user_id (matches the rest of
// the product — see util.MentionRe and frontend mention payloads).
// Humans have no Multica skills, so no skills segment is rendered.
userID := util.UUIDToString(m.MemberID)
return formatRosterRow(user.Name, "member (human)", role, "", formatMention(user.Name, "member", userID))
return formatRosterRow(user.Name, "member (human)", role, formatMention(user.Name, "member", userID))
default:
return ""
}
}
// agentSkillsRosterSegment returns the roster segment describing an agent
// member's assigned skills. "skills: a, b" when the agent has skills (the
// names are pre-sorted by ListAgentSkillSummaries), "no skills assigned" when
// it has none so the leader knows the capability is genuinely absent, and ""
// only when the lookup fails — a transient DB error degrades to the prior
// name+role row rather than asserting a misleading "no skills". Builtin
// multica-* skills are added at runtime (not in agent_skill) and are
// deliberately omitted; the leader cares about the configured capabilities.
func agentSkillsRosterSegment(ctx context.Context, q *db.Queries, agentID pgtype.UUID) string {
skills, err := q.ListAgentSkillSummaries(ctx, agentID)
if err != nil {
return ""
}
if len(skills) == 0 {
return "no skills assigned"
}
names := make([]string, 0, len(skills))
for _, s := range skills {
names = append(names, s.Name)
}
return "skills: " + strings.Join(names, ", ")
}
func formatRosterRow(name, kind, role, skills, mention string) string {
func formatRosterRow(name, kind, role, mention string) string {
var sb strings.Builder
sb.WriteString("- ")
sb.WriteString(name)
@@ -233,10 +204,6 @@ func formatRosterRow(name, kind, role, skills, mention string) string {
sb.WriteString(role)
sb.WriteString(`"`)
}
if skills != "" {
sb.WriteString(" — ")
sb.WriteString(skills)
}
sb.WriteString(" — `")
sb.WriteString(mention)
sb.WriteString("`\n")

View File

@@ -150,64 +150,6 @@ func TestBuildSquadLeaderBriefing_FullSquad(t *testing.T) {
}
}
// assignSkillToAgent creates a workspace skill and attaches it to the agent,
// registering cleanup for the skill row (agent_skill cascades on skill delete).
func assignSkillToAgent(t *testing.T, agentID, skillName string) {
t.Helper()
ctx := context.Background()
var skillID string
if err := testPool.QueryRow(ctx, `
INSERT INTO skill (workspace_id, name, description, content, created_by)
VALUES ($1, $2, '', '', $3)
RETURNING id
`, testWorkspaceID, skillName, testUserID).Scan(&skillID); err != nil {
t.Fatalf("create skill %s: %v", skillName, err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM skill WHERE id = $1`, skillID) })
if _, err := testPool.Exec(ctx,
`INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)`,
agentID, skillID,
); err != nil {
t.Fatalf("assign skill %s to agent: %v", skillName, err)
}
}
// TestBuildSquadLeaderBriefing_MemberSkillsInRoster locks in the delegation
// fix: an agent member's assigned skills appear in the leader roster so the
// leader can route by capability. Agents with no skills get an explicit
// marker; human members never carry a skills segment.
func TestBuildSquadLeaderBriefing_MemberSkillsInRoster(t *testing.T) {
ctx := context.Background()
leaderID, _ := seededLeaderAgent(t)
squad := seedSquadForBriefing(t, leaderID, "Skilled Squad", "")
skilled := createHandlerTestAgent(t, "Skilled Bot", []byte("[]"))
addAgentMember(t, squad.ID, skilled, "backend")
// ListAgentSkillSummaries orders by name ASC → "polars" before "stat…".
assignSkillToAgent(t, skilled, "polars")
assignSkillToAgent(t, skilled, "statistical-analysis")
plain := createHandlerTestAgent(t, "Plain Bot", []byte("[]"))
addAgentMember(t, squad.ID, plain, "")
memberRowID, userID, userName := seededHumanMember(t)
_ = memberRowID
addHumanMember(t, squad.ID, userID, "reviewer")
out := buildSquadLeaderBriefing(ctx, testHandler.Queries, squad)
if !strings.Contains(out, "skills: polars, statistical-analysis") {
t.Errorf("expected skilled member skills in roster, got:\n%s", out)
}
if !strings.Contains(out, "Plain Bot — agent — no skills assigned") {
t.Errorf("expected no-skills marker for skill-less agent, got:\n%s", out)
}
if strings.Contains(out, userName+" — member (human), role: \"reviewer\" — skills:") ||
strings.Contains(out, userName+" — member (human), role: \"reviewer\" — no skills") {
t.Errorf("human member must not render a skills segment, got:\n%s", out)
}
}
func TestBuildSquadLeaderBriefing_OnlyLeader(t *testing.T) {
ctx := context.Background()
leaderID, _ := seededLeaderAgent(t)
@@ -284,34 +226,34 @@ func TestBuildSquadLeaderBriefing_MentionsRoundTrip(t *testing.T) {
// claimAndDecodeAgent runs ClaimTaskByRuntime for the given runtime and
// returns the agent block of the response. Fails the test on non-200.
func claimAndDecodeAgent(t *testing.T, runtimeID string) *TaskAgentData {
t.Helper()
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-squad-briefing")
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ClaimTaskByRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
}
var resp struct {
Task *struct {
Agent *TaskAgentData `json:"agent"`
} `json:"task"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Task == nil || resp.Task.Agent == nil {
t.Fatalf("expected task.agent in response, got: %s", w.Body.String())
}
return resp.Task.Agent
t.Helper()
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-squad-briefing")
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ClaimTaskByRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
}
var resp struct {
Task *struct {
Agent *TaskAgentData `json:"agent"`
} `json:"task"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Task == nil || resp.Task.Agent == nil {
t.Fatalf("expected task.agent in response, got: %s", w.Body.String())
}
return resp.Task.Agent
}
// queueSquadIssueTaskFor creates an issue assigned to the squad and a queued
// task for the given (agentID, runtimeID). Returns the issue + task IDs.
func queueSquadIssueTaskFor(t *testing.T, squadID, agentID, runtimeID string, issueNumber int) (issueID, taskID string) {
t.Helper()
ctx := context.Background()
if err := testPool.QueryRow(ctx, `
t.Helper()
ctx := context.Background()
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_id, creator_type,
assignee_type, assignee_id, number, position
@@ -319,101 +261,101 @@ assignee_type, assignee_id, number, position
'squad', $3, $4, 0)
RETURNING id
`, testWorkspaceID, testUserID, squadID, issueNumber).Scan(&issueID); err != nil {
t.Fatalf("create squad-assigned issue: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
t.Fatalf("create squad-assigned issue: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
if err := testPool.QueryRow(ctx, `
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
VALUES ($1, $2, $3, 'queued', 0)
RETURNING id
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
t.Fatalf("queue task: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
return
t.Fatalf("queue task: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
return
}
// TestClaimTask_LeaderGetsBriefing — when the squad leader claims a task on
// a squad-assigned issue, the response's agent.instructions must include
// the Operating Protocol + Roster + user instructions.
func TestClaimTask_LeaderGetsBriefing(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var leaderID, runtimeID string
if err := testPool.QueryRow(ctx,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID, &runtimeID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
var leaderID, runtimeID string
if err := testPool.QueryRow(ctx,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID, &runtimeID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
squad := seedSquadForBriefing(t, leaderID, "Briefing Claim Squad", "Be terse.")
squad := seedSquadForBriefing(t, leaderID, "Briefing Claim Squad", "Be terse.")
helper := createHandlerTestAgent(t, "Briefing Helper", []byte("[]"))
addAgentMember(t, squad.ID, helper, "implementer")
helper := createHandlerTestAgent(t, "Briefing Helper", []byte("[]"))
addAgentMember(t, squad.ID, helper, "implementer")
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), leaderID, runtimeID, 95001)
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), leaderID, runtimeID, 95001)
agent := claimAndDecodeAgent(t, runtimeID)
for _, want := range []string{
"## Squad Operating Protocol",
"## Squad Roster",
"Leader (you):",
"## Squad Instructions (Briefing Claim Squad)",
"Be terse.",
"`[@Briefing Helper](mention://agent/" + helper + ")`",
} {
if !strings.Contains(agent.Instructions, want) {
t.Errorf("expected agent.instructions to contain %q\n--- instructions ---\n%s", want, agent.Instructions)
}
}
agent := claimAndDecodeAgent(t, runtimeID)
for _, want := range []string{
"## Squad Operating Protocol",
"## Squad Roster",
"Leader (you):",
"## Squad Instructions (Briefing Claim Squad)",
"Be terse.",
"`[@Briefing Helper](mention://agent/" + helper + ")`",
} {
if !strings.Contains(agent.Instructions, want) {
t.Errorf("expected agent.instructions to contain %q\n--- instructions ---\n%s", want, agent.Instructions)
}
}
}
// TestClaimTask_NonLeaderGetsNoBriefing — when a non-leader squad member
// claims a task on a squad-assigned issue, NO briefing is injected.
func TestClaimTask_NonLeaderGetsNoBriefing(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var leaderID string
if err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
var leaderID string
if err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 ORDER BY created_at ASC LIMIT 1`,
testWorkspaceID,
).Scan(&leaderID); err != nil {
t.Fatalf("get leader agent: %v", err)
}
squad := seedSquadForBriefing(t, leaderID, "Non-Leader Squad", "Squad guidance.")
squad := seedSquadForBriefing(t, leaderID, "Non-Leader Squad", "Squad guidance.")
// Create a second agent (NOT the leader) with its own runtime so the
// claim path picks its task without ambiguity.
helperID := createHandlerTestAgent(t, "Non Leader Helper", []byte("[]"))
addAgentMember(t, squad.ID, helperID, "")
var helperRuntime string
if err := testPool.QueryRow(ctx,
`SELECT runtime_id FROM agent WHERE id = $1`, helperID,
).Scan(&helperRuntime); err != nil {
t.Fatalf("get helper runtime: %v", err)
}
// Create a second agent (NOT the leader) with its own runtime so the
// claim path picks its task without ambiguity.
helperID := createHandlerTestAgent(t, "Non Leader Helper", []byte("[]"))
addAgentMember(t, squad.ID, helperID, "")
var helperRuntime string
if err := testPool.QueryRow(ctx,
`SELECT runtime_id FROM agent WHERE id = $1`, helperID,
).Scan(&helperRuntime); err != nil {
t.Fatalf("get helper runtime: %v", err)
}
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), helperID, helperRuntime, 95002)
queueSquadIssueTaskFor(t, util.UUIDToString(squad.ID), helperID, helperRuntime, 95002)
agent := claimAndDecodeAgent(t, helperRuntime)
for _, mustNot := range []string{
"Squad Operating Protocol",
"Squad Roster",
"Squad Instructions (Non-Leader Squad)",
} {
if strings.Contains(agent.Instructions, mustNot) {
t.Errorf("non-leader claim should NOT contain %q\n--- instructions ---\n%s", mustNot, agent.Instructions)
}
}
agent := claimAndDecodeAgent(t, helperRuntime)
for _, mustNot := range []string{
"Squad Operating Protocol",
"Squad Roster",
"Squad Instructions (Non-Leader Squad)",
} {
if strings.Contains(agent.Instructions, mustNot) {
t.Errorf("non-leader claim should NOT contain %q\n--- instructions ---\n%s", mustNot, agent.Instructions)
}
}
}
// Avoid "imported and not used: pgtype" if helpers above are the only users.

View File

@@ -138,12 +138,7 @@ agent instructions. The briefing includes:
- Squad Instructions, only when `instructions` is non-empty.
Roster entries include member name, member type, mention markdown, and non-empty
role. For agent members the roster also lists their assigned skills
(`skills: a, b`, or `no skills assigned` when the agent has none) so the leader
can delegate by capability instead of guessing from the role label; human
members carry no skills segment. Builtin `multica-*` skills are not listed —
only the workspace skills explicitly attached to the agent. Archived agent
members are skipped from the briefing roster.
role. Archived agent members are skipped from the briefing roster.
## Issue assignment behavior

View File

@@ -81,7 +81,7 @@ Contracts:
Source:
```text
server/internal/handler/squad_briefing.go # buildSquadLeaderBriefing ~104, buildSquadRoster ~121, renderMemberRow ~169, agentSkillsRosterSegment, formatRosterRow
server/internal/handler/squad_briefing.go # buildSquadLeaderBriefing ~104, buildSquadRoster ~121, renderMemberRow ~169
server/internal/handler/daemon.go # briefing injection ~1187, ~1530
```
@@ -93,10 +93,6 @@ Contracts:
(squad_briefing.go:104-117);
- `instructions` section appears only when non-empty (squad_briefing.go:110-112);
- archived agent members are skipped from roster (squad_briefing.go:178-179);
- agent member roster rows list assigned workspace skills via
`agentSkillsRosterSegment` (ListAgentSkillSummaries) — "skills: a, b" or
"no skills assigned"; builtin multica-* skills are excluded and human
members carry no skills segment (squad_briefing.go renderMemberRow);
- no traced behavior injects `instructions` into every squad member.
## Issue Assignment