Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ jobs:
go-version-file: go.mod

- name: Set up pnpm
uses: pnpm/action-setup@v4
# Pinned to commit SHA per supply-chain hygiene — third-party
# actions can be rewritten under a moving tag. Bump by re-running
# `gh api repos/pnpm/action-setup/git/refs/tags/v4`.
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 10.33.0

Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ jobs:
go-version-file: go.mod

- name: Set up pnpm
uses: pnpm/action-setup@v4
# Pinned to commit SHA per supply-chain hygiene — third-party
# actions can be rewritten under a moving tag. Bump by re-running
# `gh api repos/pnpm/action-setup/git/refs/tags/v4`.
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
# Pinned to ui/package.json packageManager so a bumped lockfile
# doesn't desync from the toolchain CI runs the build with.
Expand Down
19 changes: 2 additions & 17 deletions internal/serve/api/feed_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,23 +422,8 @@ func summariseHistoryInput(raw map[string]any, tool string) string {
if !ok {
return ""
}
get := func(k string) string {
if v, ok := in[k].(string); ok {
return v
}
return ""
}
switch tool {
case "Bash":
return truncateHistory(get("command"))
case "Edit", "Write", "Read", "MultiEdit", "NotebookEdit":
return truncateHistory(get("file_path"))
case "Glob", "Grep":
return truncateHistory(get("pattern"))
case "WebFetch":
return truncateHistory(get("url"))
case "Task":
return truncateHistory(get("description"))
if v, ok := truncateToolInputField(tool, in); ok {
return v
}
body, err := json.Marshal(in)
if err != nil {
Expand Down
65 changes: 65 additions & 0 deletions internal/serve/api/handler_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package api

import (
"net/http"
)

// requireSessionPreamble runs the boilerplate every /api/sessions/{name}/...
// JSON GET handler needs: enforce GET/HEAD only, set the standard
// Content-Type + Cache-Control headers, extract the {name} path param, and
// reject an empty name. Returns the session name on success; on failure it
// has already written the error response and the caller should return.
//
// Sonar previously flagged subagents.go, teams.go, etc. as duplicating this
// 16-line block — lifting it removes the copy-paste while keeping each
// handler's mode-specific logic local.
func requireSessionPreamble(w http.ResponseWriter, r *http.Request) (name string, ok bool) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
w.Header().Set("Cache-Control", "no-store")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return "", false
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")

name = r.PathValue("name")
if name == "" {
writeJSON(w, http.StatusBadRequest, errorBody{Error: "session name required"})
return "", false
}
return name, true
}

// truncateToolInputField returns the well-known "primary input" string for
// the named tool — Bash → command, Edit/Write/etc. → file_path, Glob/Grep
// → pattern, WebFetch → url, Task → description. Returns ok=false for tools
// the switch doesn't know about so the caller can pick its own fallback
// (the feed-history summariser JSON-marshals the whole input map; the
// subagent label scans description/prompt/query).
//
// Both shortestSubagentInputLabel (subagents.go) and summariseHistoryInput
// (feed_history.go) used to inline the same get-closure + switch — Sonar
// reported the duplicate, this consolidates the per-tool routing.
func truncateToolInputField(tool string, in map[string]any) (string, bool) {
get := func(k string) string {
if v, ok := in[k].(string); ok {
return v
}
return ""
}
switch tool {
case "Bash":
return truncateHistory(get("command")), true
case "Edit", "Write", "Read", "MultiEdit", "NotebookEdit":
return truncateHistory(get("file_path")), true
case "Glob", "Grep":
return truncateHistory(get("pattern")), true
case "WebFetch":
return truncateHistory(get("url")), true
case "Task":
return truncateHistory(get("description")), true
}
return "", false
}
36 changes: 5 additions & 31 deletions internal/serve/api/subagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,8 @@ type subagentsResponse struct {
// the api package.
func Subagents(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
w.Header().Set("Cache-Control", "no-store")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")

name := r.PathValue("name")
if name == "" {
writeJSON(w, http.StatusBadRequest, errorBody{Error: "session name required"})
name, ok := requireSessionPreamble(w, r)
if !ok {
return
}

Expand Down Expand Up @@ -317,27 +306,12 @@ func parseSubagentLine(line []byte) (subagentLineMeta, bool) {
// Mirrors the feed-row summariser's per-tool conventions so a
// subagent expanded in the UI matches the feed rows shown below it.
func shortestSubagentInputLabel(tool string, in map[string]any) string {
get := func(k string) string {
if v, ok := in[k].(string); ok {
return v
}
return ""
}
switch tool {
case "Bash":
return truncateHistory(get("command"))
case "Edit", "Write", "Read", "MultiEdit", "NotebookEdit":
return truncateHistory(get("file_path"))
case "Glob", "Grep":
return truncateHistory(get("pattern"))
case "WebFetch":
return truncateHistory(get("url"))
case "Task":
return truncateHistory(get("description"))
if v, ok := truncateToolInputField(tool, in); ok {
return v
}
// Fallback: any "description"-ish key.
for _, k := range []string{"description", "prompt", "query"} {
if v := get(k); v != "" {
if v, ok := in[k].(string); ok && v != "" {
return truncateHistory(v)
}
}
Expand Down
15 changes: 2 additions & 13 deletions internal/serve/api/teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,19 +86,8 @@ type teamsResponse struct {
// Teams returns GET /api/sessions/{name}/teams.
func Teams(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
w.Header().Set("Cache-Control", "no-store")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")

name := r.PathValue("name")
if name == "" {
writeJSON(w, http.StatusBadRequest, errorBody{Error: "session name required"})
name, ok := requireSessionPreamble(w, r)
if !ok {
return
}

Expand Down
29 changes: 29 additions & 0 deletions ui/src/components/auth/AuthField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Shared input field for the LoginForm + SignupForm. Each form previously
// declared an identical local `Field` component — Sonar flagged the block
// as duplicated. Lifted here so both forms keep identical styling without
// drifting.

interface Props {
label: string;
value: string;
onChange: (v: string) => void;
type?: string;
autoComplete?: string;
}

export function AuthField({ label, value, onChange, type = "text", autoComplete }: Props) {

Check warning on line 14 in ui/src/components/auth/AuthField.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=RandomCodeSpace_ctm&issues=AZ3itHbzM5CXM71LhHTa&open=AZ3itHbzM5CXM71LhHTa&pullRequest=9
return (
<label className="block">
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.18em] text-fg-muted">
{label}
</span>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete={autoComplete}
className="block w-full rounded border border-border bg-bg px-2 py-1.5 font-mono text-sm text-fg placeholder:text-fg-dim focus:outline-none focus:ring-1 focus:ring-accent-gold sm:text-xs"
/>
</label>
);
}
31 changes: 31 additions & 0 deletions ui/src/components/auth/serverMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, it, expect } from "vitest";
import { ApiError } from "@/lib/api";
import { serverMessage } from "./serverMessage";

describe("serverMessage", () => {
it("returns the body.message when ApiError carries a string message", () => {
const err = new ApiError(400, "bad request", { message: "username already taken" });
expect(serverMessage(err)).toBe("username already taken");
});

it("returns undefined when the error is not an ApiError", () => {
expect(serverMessage(new Error("network"))).toBeUndefined();
expect(serverMessage("oops")).toBeUndefined();
expect(serverMessage(null)).toBeUndefined();
});

it("returns undefined when the body has no message field", () => {
const err = new ApiError(500, "internal", { code: 42 });
expect(serverMessage(err)).toBeUndefined();
});

it("returns undefined when message is non-string", () => {
const err = new ApiError(400, "bad", { message: 123 });
expect(serverMessage(err)).toBeUndefined();
});

it("returns undefined when body is null or non-object", () => {
expect(serverMessage(new ApiError(400, "bad", null))).toBeUndefined();
expect(serverMessage(new ApiError(400, "bad", "string body"))).toBeUndefined();
});
});
12 changes: 12 additions & 0 deletions ui/src/components/auth/serverMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ApiError } from "@/lib/api";

// Pulls a `message` field out of an ApiError body when the server sent
// one. LoginForm and SignupForm both want this — `await mutateAsync` can
// surface a typed ApiError whose body shape is `{ message?: string }`.
export function serverMessage(e: unknown): string | undefined {
if (e instanceof ApiError && typeof e.body === "object" && e.body !== null) {
const m = (e.body as { message?: unknown }).message;
if (typeof m === "string") return m;
}
return undefined;
}
43 changes: 4 additions & 39 deletions ui/src/routes/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useState } from "react";
import { Button } from "@ossrandom/design-system";
import { ApiError } from "@/lib/api";
import { useLogin } from "@/hooks/useLogin";
import { AuthField } from "@/components/auth/AuthField";
import { serverMessage } from "@/components/auth/serverMessage";

interface Props {
onSwitchToSignup?: () => void;
Expand Down Expand Up @@ -38,8 +40,8 @@ export function LoginForm({ onSwitchToSignup }: Props) {
<div className="mx-auto mt-16 w-full max-w-sm rounded-lg border border-border bg-surface p-6">
<h1 className="mb-4 text-lg font-bold sm:text-xl">Log in to ctm</h1>
<form onSubmit={onSubmit} className="space-y-3">
<Field label="Email" type="email" value={username} onChange={setUsername} autoComplete="email" />
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
<AuthField label="Email" type="email" value={username} onChange={setUsername} autoComplete="email" />
<AuthField label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
{err && (
<div role="alert" className="text-[11px] text-alert-ember">
{err}
Expand Down Expand Up @@ -72,40 +74,3 @@ export function LoginForm({ onSwitchToSignup }: Props) {
</div>
);
}

function Field({
label,
value,
onChange,
type = "text",
autoComplete,
}: {
label: string;
value: string;
onChange: (v: string) => void;
type?: string;
autoComplete?: string;
}) {
return (
<label className="block">
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.18em] text-fg-muted">
{label}
</span>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete={autoComplete}
className="block w-full rounded border border-border bg-bg px-2 py-1.5 font-mono text-sm text-fg placeholder:text-fg-dim focus:outline-none focus:ring-1 focus:ring-accent-gold sm:text-xs"
/>
</label>
);
}

function serverMessage(e: unknown): string | undefined {
if (e instanceof ApiError && typeof e.body === "object" && e.body !== null) {
const m = (e.body as { message?: unknown }).message;
if (typeof m === "string") return m;
}
return undefined;
}
45 changes: 5 additions & 40 deletions ui/src/routes/SignupForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from "react";
import { ApiError } from "@/lib/api";
import { useSignup } from "@/hooks/useSignup";
import { AuthField } from "@/components/auth/AuthField";
import { serverMessage } from "@/components/auth/serverMessage";

interface Props {
onSwitchToLogin?: () => void;
Expand Down Expand Up @@ -42,9 +44,9 @@ export function SignupForm({ onSwitchToLogin }: Props) {
<div className="mx-auto mt-16 w-full max-w-sm rounded-lg border border-border bg-surface p-6">
<h1 className="mb-4 text-lg font-bold sm:text-xl">Create your ctm account</h1>
<form onSubmit={onSubmit} className="space-y-3">
<Field label="Email" type="email" value={username} onChange={setUsername} autoComplete="email" />
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="new-password" />
<Field label="Confirm password" type="password" value={confirm} onChange={setConfirm} autoComplete="new-password" />
<AuthField label="Email" type="email" value={username} onChange={setUsername} autoComplete="email" />
<AuthField label="Password" type="password" value={password} onChange={setPassword} autoComplete="new-password" />
<AuthField label="Confirm password" type="password" value={confirm} onChange={setConfirm} autoComplete="new-password" />
{err && (
<div role="alert" className="text-[11px] text-alert-ember">
{err}
Expand All @@ -70,40 +72,3 @@ export function SignupForm({ onSwitchToLogin }: Props) {
</div>
);
}

function Field({
label,
value,
onChange,
type = "text",
autoComplete,
}: {
label: string;
value: string;
onChange: (v: string) => void;
type?: string;
autoComplete?: string;
}) {
return (
<label className="block">
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.18em] text-fg-muted">
{label}
</span>
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete={autoComplete}
className="block w-full rounded border border-border bg-bg px-2 py-1.5 font-mono text-sm text-fg placeholder:text-fg-dim focus:outline-none focus:ring-1 focus:ring-accent-gold sm:text-xs"
/>
</label>
);
}

function serverMessage(e: unknown): string | undefined {
if (e instanceof ApiError && typeof e.body === "object" && e.body !== null) {
const m = (e.body as { message?: unknown }).message;
if (typeof m === "string") return m;
}
return undefined;
}
Loading