diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml new file mode 100644 index 000000000..c05d496f2 --- /dev/null +++ b/.github/workflows/upstream-sync.yml @@ -0,0 +1,275 @@ +# Upstream sync: keep harmoniqs/opencode current with anomalyco/opencode. +# Weekly + manual dispatch. Opens notturno/merge-upstream-YYYY-MM-DD against local/amicode. +# Keeps the fork — never drops amicode surfaces. One-off hand-merges still happen on the PR. +name: upstream-sync +on: + schedule: + # Mondays 09:00 UTC — offset from publish.yml (dev push) so upstream has landed. + - cron: "0 9 * * 1" + workflow_dispatch: + inputs: + upstream_ref: + description: "Upstream ref to merge (default: dev)" + required: false + default: "dev" + type: string + dry_run: + description: "Dry run — report overlap/conflicts but don't push a branch or open a PR" + required: false + default: false + type: boolean + +permissions: + contents: write + pull-requests: write + +concurrency: + group: upstream-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # checkout the fork's default branch head so push has a base + ref: local/amicode + + - name: Setup git committer + run: | + git config user.name "amico-sync-bot" + git config user.email "amico-sync@harmoniqs.local" + + - name: Add upstream and fetch + id: upstream + run: | + set -euo pipefail + if git remote get-url upstream >/dev/null 2>&1; then + git remote set-url upstream https://github.com/anomalyco/opencode.git + else + git remote add upstream https://github.com/anomalyco/opencode.git + fi + # sst/opencode → anomalyco/opencode (0cf029478). Keep sst as fallback fetch if anomalyco is slow. + git fetch upstream "${{ inputs.upstream_ref || 'dev' }}" --prune + SHA=$(git rev-parse "upstream/${{ inputs.upstream_ref || 'dev' }}") + SHORT=$(git rev-parse --short "$SHA") + DATE=$(date -u +%Y-%m-%d) + # version from upstream package.json if present + VER=$(git show "upstream/${{ inputs.upstream_ref || 'dev' }}:packages/opencode/package.json" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('version',''))" || echo "") + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + echo "short=$SHORT" >> "$GITHUB_OUTPUT" + echo "date=$DATE" >> "$GITHUB_OUTPUT" + echo "version=$VER" >> "$GITHUB_OUTPUT" + echo "upstream SHA=$SHA ($SHORT) version=$VER date=$DATE" + + - name: Create sync branch + id: branch + run: | + set -euo pipefail + BRANCH="notturno/merge-upstream-${{ steps.upstream.outputs.date }}" + # if branch already exists locally or on origin, suffix with short SHA + if git rev-parse --verify "$BRANCH" >/dev/null 2>&1 || git ls-remote --exit-code origin "$BRANCH" >/dev/null 2>&1; then + BRANCH="${BRANCH}-${{ steps.upstream.outputs.short }}" + fi + git checkout -b "$BRANCH" "origin/local/amicode" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + echo "created $BRANCH from origin/local/amicode" + + - name: Attempt merge (no commit) + id: merge + run: | + set -euo pipefail + set +e + git merge --no-ff --no-commit "upstream/${{ inputs.upstream_ref || 'dev' }}" + EC=$? + set -e + echo "exit_code=$EC" >> "$GITHUB_OUTPUT" + if [ "$EC" -eq 0 ]; then + echo "conflicts=0" >> "$GITHUB_OUTPUT" + echo "conflict_files=" >> "$GITHUB_OUTPUT" + echo "merge clean" + else + # collect conflicted paths + FILES=$(git diff --name-only --diff-filter=U | tr '\n' ' ' | xargs || true) + COUNT=$(git diff --name-only --diff-filter=U | wc -l | xargs) + echo "conflicts=$COUNT" >> "$GITHUB_OUTPUT" + echo "conflict_files=$FILES" >> "$GITHUB_OUTPUT" + echo "conflicts=$COUNT files: $FILES" + # keep working tree conflicted for report step, then abort after report + fi + # overlap stats for report (even on clean merges) + git diff --name-only --diff-filter=U > /tmp/conflicted.txt 2>/dev/null || true + # overall diff stats upstream..HEAD + git diff --stat "upstream/${{ inputs.upstream_ref || 'dev' }}" -- . > /tmp/upstream-stat.txt 2>/dev/null || true + + - name: Dry run — report only + if: ${{ inputs.dry_run == true }} + run: | + cat <<'EOF' + Dry run — no branch pushed, no PR opened. + EOF + echo "upstream=${{ steps.upstream.outputs.sha }} (${{ steps.upstream.outputs.short }}) version=${{ steps.upstream.outputs.version }}" + echo "branch=${{ steps.branch.outputs.branch }}" + echo "exit_code=${{ steps.merge.outputs.exit_code }}" + echo "conflicts=${{ steps.merge.outputs.conflicts }}" + echo "files=${{ steps.merge.outputs.conflict_files }}" + echo "--- upstream diff stat (first 50 lines) ---" + head -n 50 /tmp/upstream-stat.txt || true + if [ "${{ steps.merge.outputs.exit_code }}" -ne 0 ]; then + git merge --abort || true + else + git merge --abort || true + fi + + - name: Commit clean merge + if: ${{ inputs.dry_run != true && steps.merge.outputs.exit_code == 0 }} + run: | + set -euo pipefail + BRANCH="${{ steps.branch.outputs.branch }}" + SHA="${{ steps.upstream.outputs.sha }}" + SHORT="${{ steps.upstream.outputs.short }}" + VER="${{ steps.upstream.outputs.version }}" + DATE="${{ steps.upstream.outputs.date }}" + cat > /tmp/commit-msg.txt < + EOF + git commit -m "$(cat /tmp/commit-msg.txt)" + git log --oneline -2 + git push -u origin "$BRANCH" + + - name: Commit conflict report (hand-merge needed) + if: ${{ inputs.dry_run != true && steps.merge.outputs.exit_code != 0 }} + run: | + set -euo pipefail + BRANCH="${{ steps.branch.outputs.branch }}" + SHA="${{ steps.upstream.outputs.sha }}" + SHORT="${{ steps.upstream.outputs.short }}" + VER="${{ steps.upstream.outputs.version }}" + COUNT="${{ steps.merge.outputs.conflicts }}" + FILES="${{ steps.merge.outputs.conflict_files }}" + # abort the conflicted merge state — we push a report branch, not conflict markers + git merge --abort || true + mkdir -p .upstream-sync + cat > .upstream-sync/report.md <} + \`\`\` + + ## Full conflict list + \`\`\` + $(git diff --name-only --diff-filter=U 2>/dev/null || cat /tmp/conflicted.txt 2>/dev/null || echo "") + \`\`\` + + ## Next steps + 1. \`git fetch upstream && git checkout ${BRANCH} && git merge upstream/${{ inputs.upstream_ref || 'dev' }}\` + 2. Resolve per AMICODE-PATCHES.md policy: + - adopt upstream bugfixes wholesale + - keep fork branding/KaTeX/AmicoSpinner/entity-rail/bug-dock (\`vaults.ts\`, \`draft-store.ts\`, \`marked\` macros) + - re-delete \`debug-bar.tsx\` (fork keeps it deleted) + - \`bun.lock\` → theirs + \`bun install\` + - i18n: re-run \`script/translate-app.ts\` or copy EN fallbacks + 3. Update \`AMICODE-PATCHES.md\` header + new sync section, bump \`package.json\` versions to \`${VER}\`. + 4. Verify: \`env -u OPENCODE_CONFIG_CONTENT -u OPENCODE_SERVER_PASSWORD bun test\`, \`bun run typecheck\`, and \`OPENCODE_CHANNEL=dev\` build gate (\`grep newLayoutDesigns\`). + 5. Push — PR auto-updates. + + _Generated by .github/workflows/upstream-sync.yml_ + EOF + cat .upstream-sync/report.md + git add .upstream-sync/report.md + git commit -m "chore: upstream sync report for ${VER:-dev} @ ${SHORT} — ${COUNT} conflicts need hand-merge + + Upstream ${SHA} into ${BRANCH}. See .upstream-sync/report.md. + Automated by .github/workflows/upstream-sync.yml" + git push -u origin "$BRANCH" + + - name: Open PR (clean merge) + if: ${{ inputs.dry_run != true && steps.merge.outputs.exit_code == 0 }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + BRANCH="${{ steps.branch.outputs.branch }}" + SHA="${{ steps.upstream.outputs.sha }}" + SHORT="${{ steps.upstream.outputs.short }}" + VER="${{ steps.upstream.outputs.version }}" + cat > /tmp/pr-body.md < /tmp/pr-body.md < {/* amicode: Amico's working presence — the offset accent lane. Shown only while an in-domain turn streams (state === "on"), decoupled from any - card-suppression: the thinking block runs (H-mark + cycling gerund + - live elapsed/tokens — it owns its mark now, no lane-head here; two - signature marks side by side compete with each other). Pops out the + card-suppression: the thinking block (wave + cycling gerund + live + elapsed/tokens) runs without a duplicate mark. Pops out the moment working flips false. spec-20260712-amico-third-actor. */}
diff --git a/packages/ui/src/amicode/amico-presence.stories.tsx b/packages/ui/src/amicode/amico-presence.stories.tsx index 28eaee4cb..237e4a4f5 100644 --- a/packages/ui/src/amicode/amico-presence.stories.tsx +++ b/packages/ui/src/amicode/amico-presence.stories.tsx @@ -87,8 +87,7 @@ function MockFlow(props: { state: PresenceState }) { {/* Amico lane */}
- {/* stepping_in gets a bare lane-head; once working, the thinking block - OWNS its H-mark (thinking-line.tsx) so no lane-head here */} + {/* stepping_in gets a bare lane-head; the thinking block (wave+verb) owns the working indicator now */}
stepping in…
diff --git a/packages/ui/src/amicode/amico-wave.stories.tsx b/packages/ui/src/amicode/amico-wave.stories.tsx index 7f89918ec..c54348fb5 100644 --- a/packages/ui/src/amicode/amico-wave.stories.tsx +++ b/packages/ui/src/amicode/amico-wave.stories.tsx @@ -40,7 +40,6 @@ // mapping from its source file, so it tracks theme changes instead of a hand-copied hex. import { createSignal, For, onMount } from "solid-js" import { AmicoWave } from "./amico-wave" -import { AmicoMark } from "./spinner" import { MODE_WAVELENGTHS, WAVE_BOX } from "./wave-geometry" import oc2ThemeJson from "../theme/themes/oc-2.json" import { resolveThemeVariantV2 } from "../theme/v2/resolve" @@ -111,14 +110,13 @@ function SchemePane(props: { scheme: "light" | "dark"; children: unknown }) { ) } -// Mimics the real mount site (thinking-line.tsx): H-mark, glyph and bold gerund -// on the top row; the muted meta below, starting under the glyph — reusing the -// actual .amc-thinking* classes from amicode.css. DOM order is word-first so -// the block's first baseline is the verb's (see thinking-line.tsx). +// Mimics the real mount site (thinking-line.tsx): glyph and bold gerund on the +// top row; the muted meta below, starting under the glyph — reusing the actual +// .amc-thinking* classes from amicode.css. DOM order is word-first so the +// block's first baseline is the verb's (see thinking-line.tsx). const ThinkingRow = () => ( Percolating… - 5m 13s · ↑ 2.4k tokens diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index a7878b799..ba5b8230f 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -53,13 +53,13 @@ } /* ---- thinking block (Claude-Code-esque working indicator) ---------------- */ -/* Two-row grid shown while a reply streams (thinking-line.tsx): the H-mark, - * harmonic wave and cycling gerund ride the top row; the live meta line - * (elapsed · tokens · esc) sits below, starting under the wave. Anti-glitch - * invariants, earned the hard way: +/* Two-row grid shown while a reply streams (thinking-line.tsx): the harmonic + * wave and cycling gerund ride the top row; the live meta line (elapsed · + * tokens · esc) sits below, starting under the wave. Anti-glitch invariants, + * earned the hard way: * - GRID, never flex-wrap — the old flex row once let a greedy sibling * heading squeeze the meta until "↑ 8.7k tokens" shattered across three - * lines, and baseline alignment scattered the mark/wave/verb; + * lines, and baseline alignment scattered the wave/verb; * - the verb reserves the longest gerund's width (16ch covers * "Fidelitymaxxing…") so word rotation never shifts the layout around it; * - the meta renders from mount (elapsed always ticks), so the block's height @@ -67,10 +67,10 @@ * - nowrap + ellipsis on the meta for absurdly narrow panes. * DOM order is word-first so the block's first baseline is the VERB's — the * sibling activity heading (session-turn-thinking) aligns with the verb, not - * the mark's bottom edge. Explicit grid placement keeps the visual order. */ + * the wave's edge. Explicit grid placement keeps the visual order. */ .amc-thinking { display: inline-grid; - grid-template-columns: auto auto 1fr; + grid-template-columns: auto 1fr; column-gap: 8px; row-gap: 2px; align-items: center; @@ -80,10 +80,9 @@ letter-spacing: 0.01em; color: var(--v2-text-text-muted); } -.amc-thinking .amc-mark { grid-column: 1; grid-row: 1; } -.amc-thinking .amc-wave { grid-column: 2; grid-row: 1; } +.amc-thinking .amc-wave { grid-column: 1; grid-row: 1; } .amc-thinking-word { - grid-column: 3; + grid-column: 2; grid-row: 1; font-weight: 600; white-space: nowrap; @@ -91,7 +90,7 @@ color: var(--v2-text-text-accent); } .amc-thinking-meta { - grid-column: 2 / -1; + grid-column: 1 / -1; grid-row: 2; min-width: 0; overflow: hidden; diff --git a/packages/ui/src/amicode/thinking-line.stories.tsx b/packages/ui/src/amicode/thinking-line.stories.tsx index 76c507b62..a5ef75449 100644 --- a/packages/ui/src/amicode/thinking-line.stories.tsx +++ b/packages/ui/src/amicode/thinking-line.stories.tsx @@ -11,11 +11,11 @@ export default { component: `### Thinking block The Claude-Code-esque "working" indicator shown while a reply streams. A -two-row grid block that OWNS its H-mark: the mark, harmonic wave glyph and -cycling gerund ride the top row; the live meta line (elapsed · tokens · esc) -sits below, starting under the wave. The verb reserves the longest gerund's -width so word rotation never shifts the layout, and the meta can never wrap -mid-phrase. +two-row grid block: the harmonic wave glyph and cycling gerund ride the top +row; the live meta line (elapsed · tokens · esc) sits below, starting under +the wave. The verb reserves the longest gerund's width so word rotation never +shifts the layout, and the meta can never wrap mid-phrase. Verbs are shuffled +per mount so each turn opens on a different word. Under \`prefers-reduced-motion\` the wave and word are static; the elapsed counter still advances (it's information, not decoration).`, diff --git a/packages/ui/src/amicode/thinking-line.tsx b/packages/ui/src/amicode/thinking-line.tsx index 69d7f7513..2250f24ea 100644 --- a/packages/ui/src/amicode/thinking-line.tsx +++ b/packages/ui/src/amicode/thinking-line.tsx @@ -1,15 +1,12 @@ import { createSignal, onCleanup, onMount, Show, type ComponentProps } from "solid-js" -import { wordAt, formatElapsed, formatTokens } from "./thinking" +import { shuffledWordAt, SHUFFLED_WORDS, formatElapsed, formatTokens } from "./thinking" import { AmicoWave } from "./amico-wave" -import { AmicoMark } from "./spinner" // AMICODE: the "thinking" working indicator — a two-row block shown while a // reply streams (app TimelineThinkingRow; session-ui lane in message-part.tsx). -// Row 1: H-mark + harmonic wave (amico-wave.tsx) + the cycling gerund. Row 2: -// the live meta line (elapsed · tokens · esc), starting under the wave. The -// block OWNS the mark — mount sites no longer add their own (a `mark={false}` -// opt-out remains for contexts that carry their own signature). Pure bits -// (word rotation, label formatting) live in ./thinking for testing. +// Row 1: harmonic wave (amico-wave.tsx) + the cycling gerund. Row 2: +// the live meta line (elapsed · tokens · esc), starting under the wave. +// Pure bits (word rotation, label formatting) live in ./thinking for testing. // // Layout invariants (earned the hard way — the CSS side lives in amicode.css): // - the meta can NEVER wrap mid-phrase — the old flex row once let a greedy @@ -47,14 +44,16 @@ export function ThinkingLine(props: { tokens?: number /** show the "esc to interrupt" hint — only pass when the mount site wires esc */ interruptible?: boolean - /** render the H-mark as the block's anchor (default true) */ + /** @deprecated no-op — the H-mark was removed (wave+verb is the indicator now); kept for compat */ mark?: boolean class?: string style?: ComponentProps<"span">["style"] }) { const still = reducedMotion() const [elapsedMs, setElapsedMs] = createSignal(0) - const [tick, setTick] = createSignal(0) + // Random start so each turn/mount opens on a different verb; the module-level + // shuffle already decorrelates reloads, this decorrelates simultaneous mounts. + const [tick, setTick] = createSignal(Math.floor(Math.random() * SHUFFLED_WORDS.length)) onMount(() => { const start = Date.now() @@ -66,7 +65,7 @@ export function ThinkingLine(props: { } }) - const word = () => (still ? "Thinking" : wordAt(tick())) + const word = () => (still ? "Thinking" : shuffledWordAt(tick())) return ( - - -