Skip to content
Closed
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
20 changes: 20 additions & 0 deletions AMICODE-PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,23 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build.
- index.css: @font-face for both (JuliaMono full glyph set — Julia Unicode; Racing Sans One latin subset, font-display swap). logo.tsx + wordmark-v2.tsx: font-family 'Racing Sans One' first, weight 700→400. settings.tsx: monoDefault/monoFallback lead with JuliaMono. theme.css: --font-family-mono leads with JuliaMono. Terminal font DELIBERATELY unchanged (JetBrainsMono Nerd Font Mono via separate terminalFallback).
- New assets (git-added — build breaks without them): public/assets/RacingSansOne-Regular.woff2 (21 KB) + JuliaMono-Regular.woff2 (946 KB).
- Font build sha256: `2a15da111be516516fb1fbd1a4fb5ae02ad9bddd6373ede08cdf7b28c19d163a` (dist/opencode-local + vendored path, write-temp + mv -f swap; SUPERSEDES #13's a73d8583… — same code, fonts now committed). Verify (scratch port 14099): `GET /assets/RacingSansOne-Regular.woff2` → 200 font/woff2 21804 B; `GET /assets/JuliaMono-Regular.woff2` → 200 font/woff2 946516 B; "Racing Sans One" in built css + index/new-session chunks, "JuliaMono" in `index-Dwtxigfs.css`; `GET /amicode/problems` → 200; `GET /` → 200 `<title>Amicode</title>`; ui `bun test src` → 70 pass; typecheck green ui+app (no snapshots assert fonts, per Aaron — confirmed nothing went red). Bonus confirmation: KaTeX\_\* woff2 assets now in dist — the entity view's katex import (#13) pulls its font set into the embed.
9. (home CTA fallback) — amicode(home): "Open chat" works on a fresh profile.
- BUG: `startWithPrompt` (fork wiring for the Meet-Amico card, patch 5ef6b7e0e) dead-ended
silently when the persisted client-side project list was empty (fresh browser profile
against a bare `opencode serve`): the `!project` branch called `openNewSession()`, which
needs the SAME empty `newSessionProject()` and hits `if (!conn || !project) return`.
Primary home CTA did nothing, no error. Hit live 2026-07-08 (web UI on a scratch dir).
- FIX (packages/app/src/pages/home.tsx, `startWithPrompt` only): when no project is
tracked, fall back to the focused server's own working directory —
`focusedSync().data.path.directory` (synced from GET /path; "" until loaded, so the
falsy guard holds) — open+touch it as a project (self-heals the home page), then
`tabs.newDraft` with the prompt preserved. Deliberately NOT `sync.data.project`:
the server's "global" project record has worktree "/".
- Regression spec: packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts —
fresh profile (NO localStorage seed), mocked server, click the CTA (`exact: true` —
the whole card is also a button whose accessible name contains "Open chat"), expect
navigation to `/new-session?draftId=` + the cwd persisted as a tracked project.
Verified failing on the unfixed code, passing with the fix. Playwright note: config
reuses any server on port 3000 (`reuseExistingServer`) — run with `PLAYWRIGHT_PORT=<free>`
if something else (e.g. the harmoniqs website dev server) holds 3000.
- Checks: `tsgo -b` clean; `bun run test:unit` 376 pass / 0 fail.
33 changes: 33 additions & 0 deletions packages/app/e2e/regression/home-open-chat-empty-projects.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"

// Regression: on a fresh profile (no tracked projects in localStorage) the
// home "Open chat" CTA dead-ended silently — startWithPrompt fell through to
// openNewSession(), which needs the same newSessionProject() that just came
// back empty. It must instead fall back to the server's own working
// directory (GET /path → .directory) and start a draft there, tracking the
// directory as a project so the rest of the home page works from then on.
test("home 'Open chat' falls back to the server cwd on a fresh profile", async ({ page }) => {
await mockOpenCodeServer(page, {
sessions: [],
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})

// Deliberately NO localStorage seed — an empty tracked-project list is the
// regression condition (contrast: session-list-path-loading.spec.ts seeds it).
await page.goto("/")
// exact: true — the whole Meet-Amico card is also a button whose accessible
// name contains "Open chat"; we want the CTA inside it.
await page.getByRole("button", { name: "Open chat", exact: true }).click()

// Navigates to a new-session draft instead of doing nothing.
await expect(page).toHaveURL(/\/new-session\?draftId=/)

// And the server cwd is now a tracked project (the self-healing part).
const persisted = await page.evaluate(() => localStorage.getItem("opencode.global.dat:server") ?? "")
expect(persisted).toContain(fixture.directory)
})
103 changes: 90 additions & 13 deletions packages/app/src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
import { amicodeGet, amicodePost } from "@/utils/amicode-fetch"
import { AmicodeRunGallery } from "@opencode-ai/ui/amicode-run-gallery"
import { AmicodeOnboardingWizard, shouldShowWizard } from "@opencode-ai/ui/amicode-onboarding-wizard"
import { parseRunCardsResponse } from "@opencode-ai/ui/amicode-run-card"
import { AmicodeHomeCards, parseProfileResponse, type HomeLiveRun } from "@opencode-ai/ui/amicode-home-cards"
import { parseRunSeriesResponse } from "@opencode-ai/ui/amicode-run-window"
import { AMICODE_STARTERS } from "@opencode-ai/ui/amicode-getting-started"
import { parseProblemsResponse } from "@opencode-ai/ui/amicode-problem-switcher"
import { parseProblemResponse } from "@opencode-ai/ui/amicode-entity-view"
import { Mark } from "@opencode-ai/ui/logo"
Expand Down Expand Up @@ -339,13 +339,81 @@ function HomeDesign() {
})

const [sessionsExpanded, setSessionsExpanded] = createSignal(false)
// Shared by the About-You card and the onboarding wizard: identity fields
// ride query params on the raw POST route; refetch renders the saved state.
async function saveProfileFields(fields: Record<string, string | undefined>) {
const q = new URLSearchParams()
for (const [k, v] of Object.entries(fields)) if (v !== undefined) q.set(k, v)
await amicodePost(focusedServer(), `/amicode/profile?${q.toString()}`)
await refetchProfile()
}

// Onboarding wizard (session zero): decided ONCE when the profile first
// resolves — the mid-wizard profile refetch must not unmount the preview
// step, and a dismiss is remembered per install (localStorage).
// Library (papers that make Amico smarter): count + latest for the card.
const [libraryRaw, { refetch: refetchLibrary }] = createResource(
() => state.selection.server,
() => amicodeGet(focusedServer(), "/amicode/library").catch(() => undefined),
)
const libraryView = createMemo(() => {
const raw = libraryRaw() as { ok?: boolean; papers?: { name?: string; path?: string }[] } | undefined
if (!raw || raw.ok !== true || !Array.isArray(raw.papers)) return undefined
return {
count: raw.papers.length,
latestName: typeof raw.papers[0]?.name === "string" ? raw.papers[0].name : undefined,
latestPath: typeof raw.papers[0]?.path === "string" ? raw.papers[0].path : undefined,
}
})
async function uploadPaper(filename: string, dataB64: string) {
const res = await amicodePost(focusedServer(), "/amicode/library", { filename, data_b64: dataB64 })
if ((res as { ok?: boolean } | undefined)?.ok !== true) throw new Error("library save rejected")
await refetchLibrary()
}

const WIZARD_DISMISS_KEY = "amicode-onboarding-dismissed"
const [wizardOpen, setWizardOpen] = createSignal(false)
let wizardDecided = false
createEffect(() => {
const view = profileView()
if (wizardDecided || view === undefined || !view.ok) return
wizardDecided = true
let dismissed = false
try {
dismissed = localStorage.getItem(WIZARD_DISMISS_KEY) === "1"
} catch {
/* storage unavailable → treat as not dismissed */
}
setWizardOpen(shouldShowWizard(view.you, dismissed))
})
const dismissWizard = () => {
try {
localStorage.setItem(WIZARD_DISMISS_KEY, "1")
} catch {
/* best-effort */
}
setWizardOpen(false)
}

function startWithPrompt(prompt: string) {
const project = newSessionProject()
if (!project) {
openNewSession()
if (project) {
tabs.newDraft({ server: server.key, directory: project.worktree }, prompt)
return
}
tabs.newDraft({ server: server.key, directory: project.worktree }, prompt)
// No tracked projects (fresh profile against a bare `opencode serve`):
// openNewSession() would dead-end silently here — it needs the same
// newSessionProject() that just came back empty. Fall back to the server's
// own working directory (path.directory, synced from GET /path; "" until
// loaded) and start tracking it, so the home CTAs work on first visit.
// Deliberately NOT sync.data.project: its "global" record has worktree "/".
const conn = focusedServer()
const directory = focusedSync().data.path.directory
if (!conn || !directory) return
const ctx = global.createServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
tabs.newDraft({ server: ServerConnection.key(conn), directory }, prompt)
}

function setSelection(next: HomeProjectSelection) {
Expand Down Expand Up @@ -630,17 +698,11 @@ function HomeDesign() {
<div class="relative z-[1] flex-none pt-1">
<AmicodeHomeCards
profile={profileView()}
starters={AMICODE_STARTERS}
onStart={startWithPrompt}
library={libraryView()}
onUploadPaper={uploadPaper}
onEditProfile={() => startWithPrompt("update my profile — my name, affiliation, and what I work on")}
onSaveProfile={async (fields) => {
// In-place save (About-You card): identity fields ride query
// params on the raw POST route; refetch renders the saved state.
const q = new URLSearchParams()
for (const [k, v] of Object.entries(fields)) if (v !== undefined) q.set(k, v)
await amicodePost(focusedServer(), `/amicode/profile?${q.toString()}`)
await refetchProfile()
}}
onSaveProfile={saveProfileFields}
resumeName={resumeProblem()?.name}
resumeMeta={resumeMeta()}
onResume={() => {
Expand All @@ -657,6 +719,21 @@ function HomeDesign() {
/>
</div>
<AmicodeFooter />
<Show when={wizardOpen()}>
<AmicodeOnboardingWizard
initialName={(() => {
const v = profileView()
return v?.ok ? v.you.name : ""
})()}
onComplete={saveProfileFields}
onUploadPaper={uploadPaper}
onDismiss={dismissWizard}
onOpenChat={() => {
dismissWizard()
startWithPrompt("")
}}
/>
</Show>
<Show when={galleryOpen()}>
<AmicodeRunGallery
cards={runCards()}
Expand Down
13 changes: 11 additions & 2 deletions packages/app/src/utils/amicode-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,24 @@ export async function amicodeGet(conn: ServerConnection.Any | undefined, route:
/** POST sibling of amicodeGet — the amicode raw routes keep params in the URL
* (no body), so this is the same call shape with method POST. Used by the
* About-You card's in-place profile save. */
export async function amicodePost(conn: ServerConnection.Any | undefined, route: string): Promise<unknown> {
export async function amicodePost(
conn: ServerConnection.Any | undefined,
route: string,
jsonBody?: unknown,
): Promise<unknown> {
if (!conn) throw new Error("no active server")
const headers: Record<string, string> = {}
if (conn.http.password)
headers.Authorization = `Basic ${authTokenFromCredentials({
username: conn.http.username,
password: conn.http.password,
})}`
const res = await fetch(new URL(route, conn.http.url), { method: "POST", headers })
if (jsonBody !== undefined) headers["content-type"] = "application/json"
const res = await fetch(new URL(route, conn.http.url), {
method: "POST",
headers,
...(jsonBody !== undefined ? { body: JSON.stringify(jsonBody) } : {}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return (await res.json()) as unknown
}
83 changes: 83 additions & 0 deletions packages/opencode/src/server/amicode/library.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs"
import os from "os"
import path from "path"

// AMICODE: the user's paper library — PDFs uploaded from the home page that
// make Amico smarter about THIS user's work. Files land in ~/.amico/library;
// the amicode extension grants the agent's file tools read access to that dir,
// so "read the paper I just added" works with zero further plumbing. Same
// never-reject discipline as problems.ts: every body is a JSON string.

export function libraryRoot(): string {
const env = process.env.AMICODE_LIBRARY_DIR
if (env && env.trim() !== "") return env
return path.join(os.homedir(), ".amico", "library")
}

export function synthesizeLibrary(code: string, detail: string): string {
return JSON.stringify({ ok: false, papers: [], error: `${code}: ${detail}` })
}

const MAX_BYTES = 30 * 1024 * 1024 // a 30MB PDF is a book; bigger is a mistake

/** Basename-only, conservative charset, single .pdf suffix. */
export function sanitizeFilename(raw: string): string | null {
const base = path.basename(raw).trim()
if (!/\.pdf$/i.test(base)) return null
const clean = base
.slice(0, -4)
.replace(/[^\w.\- ]+/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120)
return clean === "" ? null : `${clean}.pdf`
}

export function libraryBody(root: string = libraryRoot()): string {
try {
if (!existsSync(root)) return JSON.stringify({ ok: true, papers: [], error: null })
const papers = readdirSync(root)
.filter((f) => f.toLowerCase().endsWith(".pdf"))
.map((f) => {
const st = statSync(path.join(root, f))
return { name: f, size: st.size, added_ms: Math.round(st.mtimeMs), path: path.join(root, f) }
})
.sort((a, b) => b.added_ms - a.added_ms)
return JSON.stringify({ ok: true, papers, error: null })
} catch (err) {
return synthesizeLibrary("bad_output", String(err))
}
}

/** Save one uploaded paper (JSON body: {filename, data_b64}). Returns the
* refreshed listing on success so the client renders in one round-trip. */
export function saveLibraryFile(rawBody: string, root: string = libraryRoot()): string {
let parsed: { filename?: unknown; data_b64?: unknown }
try {
parsed = JSON.parse(rawBody)
} catch {
return synthesizeLibrary("bad_request", "body must be JSON {filename, data_b64}")
}
if (typeof parsed.filename !== "string" || typeof parsed.data_b64 !== "string")
return synthesizeLibrary("bad_request", "filename and data_b64 are required strings")
const name = sanitizeFilename(parsed.filename)
if (!name) return synthesizeLibrary("bad_filename", "PDFs only; name must survive sanitization")
let bytes: Buffer
try {
bytes = Buffer.from(parsed.data_b64, "base64")
} catch {
return synthesizeLibrary("bad_request", "data_b64 is not valid base64")
}
if (bytes.length === 0) return synthesizeLibrary("bad_request", "empty file")
if (bytes.length > MAX_BYTES) return synthesizeLibrary("too_large", `max ${MAX_BYTES} bytes`)
// magic check: every real PDF opens with %PDF-
if (!bytes.subarray(0, 5).equals(Buffer.from("%PDF-")))
return synthesizeLibrary("bad_filetype", "not a PDF (missing %PDF- header)")
try {
mkdirSync(root, { recursive: true })
writeFileSync(path.join(root, name), bytes)
} catch (err) {
return synthesizeLibrary("write_failed", String(err))
}
return libraryBody(root)
}
Loading
Loading