Merge branch 'NeuralNomadsAI:dev' into bugfix/tabCloseBug

This commit is contained in:
Aayurt Shrestha 2026-06-13 23:09:53 +05:45 committed by GitHub
commit bbfc2ace22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 465 additions and 12 deletions

View file

@ -97,6 +97,18 @@ jobs:
release_name: ${{ needs.prepare-release.outputs.release_name }}
secrets: inherit
update-winget:
needs:
- prepare-release
- build-and-upload
if: ${{ !inputs.prerelease }}
permissions:
contents: read
uses: ./.github/workflows/update-winget.yml
with:
release_tag: ${{ needs.prepare-release.outputs.tag }}
secrets: inherit
release-ui:
needs: prepare-release
if: ${{ inputs.release_ui }}

View file

@ -1,17 +1,83 @@
name: Update Winget
on:
release:
types:
- published
workflow_call:
inputs:
release_tag:
description: "Stable release tag to inspect"
required: true
type: string
release_id:
description: "Optional numeric GitHub release id"
required: false
default: ""
type: string
workflow_dispatch:
inputs:
release_tag:
description: "Stable release tag to inspect"
required: true
type: string
release_id:
description: "Optional numeric GitHub release id"
required: false
default: ""
type: string
permissions:
contents: read
jobs:
resolve-release:
name: Resolve release metadata
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.release.outputs.release_id }}
release_tag: ${{ steps.release.outputs.release_tag }}
draft: ${{ steps.release.outputs.draft }}
prerelease: ${{ steps.release.outputs.prerelease }}
steps:
- name: Resolve release metadata
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag }}
RELEASE_ID_INPUT: ${{ inputs.release_id }}
run: |
set -euo pipefail
if [ -n "$RELEASE_ID_INPUT" ]; then
release_api="repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID_INPUT}"
else
release_api="repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}"
fi
release_id="$(gh api "$release_api" --jq '.id')"
release_tag="$(gh api "$release_api" --jq '.tag_name')"
draft="$(gh api "$release_api" --jq '.draft')"
prerelease="$(gh api "$release_api" --jq '.prerelease')"
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
echo "Unable to resolve release id for tag '$RELEASE_TAG'" >&2
exit 1
fi
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
echo "draft=$draft" >> "$GITHUB_OUTPUT"
echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
- name: Log resolved release metadata
run: |
echo "Release tag: ${{ steps.release.outputs.release_tag }}"
echo "Release id: ${{ steps.release.outputs.release_id }}"
echo "Draft: ${{ steps.release.outputs.draft }}"
echo "Prerelease: ${{ steps.release.outputs.prerelease }}"
update-winget:
name: Submit Winget manifest update
if: ${{ !github.event.release.draft && !github.event.release.prerelease }}
needs: resolve-release
if: ${{ needs.resolve-release.outputs.draft != 'true' && needs.resolve-release.outputs.prerelease != 'true' }}
runs-on: ubuntu-latest
env:
WINGET_PACKAGE_IDENTIFIER: ${{ vars.WINGET_PACKAGE_IDENTIFIER || 'NeuralNomadsAI.CodeNomad' }}
@ -35,8 +101,8 @@ jobs:
run: |
args=(
--repo "${{ github.repository }}"
--release-id "${{ github.event.release.id }}"
--tag "${{ github.event.release.tag_name }}"
--release-id "${{ needs.resolve-release.outputs.release_id }}"
--tag "${{ needs.resolve-release.outputs.release_tag }}"
--asset-name-template "$WINGET_WINDOWS_ASSET_NAME_TEMPLATE"
--timeout-seconds "$WINGET_ASSET_WAIT_TIMEOUT_SECONDS"
--poll-interval-seconds "$WINGET_ASSET_POLL_INTERVAL_SECONDS"
@ -79,7 +145,7 @@ jobs:
with:
identifier: ${{ env.WINGET_PACKAGE_IDENTIFIER }}
version: ${{ steps.release_asset.outputs.version }}
release-tag: ${{ github.event.release.tag_name }}
release-tag: ${{ needs.resolve-release.outputs.release_tag }}
installers-regex: ${{ steps.release_asset.outputs.asset_regex }}
fork-user: ${{ env.WINGET_FORK_OWNER }}
token: ${{ secrets.WINGET_GITHUB_TOKEN }}

154
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,154 @@
# Contributing to CodeNomad
Thank you for your interest in contributing! This guide will help you get started.
## Prerequisites
- **Node.js 18+** and npm
- **OpenCode CLI** in your `PATH` (the server connects to the OpenCode binary to manage workspaces)
## Quick Start
```bash
git clone https://github.com/NeuralNomadsAI/CodeNomad.git
cd CodeNomad
npm install
npm run dev
```
## Finding Issues to Work On
Browse [open issues](https://github.com/NeuralNomadsAI/CodeNomad/issues) and look for these labels:
| Label | Meaning |
|---|---|
| `ready-to-work` | Clear scope, ready for anyone to pick up |
| `good-first-issue` | Good for first-time contributors |
| `enhancement` | New feature requests |
| `bug` | Bug reports |
**Before starting:** comment on the issue so we can discuss approach and avoid duplicate work.
## Development Workflow
### 1. Fork and Branch
```bash
# Fork the repo on GitHub, then clone your fork
git clone https://github.com/YOUR_USERNAME/CodeNomad.git
cd CodeNomad
# Add the upstream remote
git remote add upstream https://github.com/NeuralNomadsAI/CodeNomad.git
# Create a branch from upstream/dev
git fetch upstream
git checkout -b fix/your-branch-name upstream/dev
```
### 2. Branch Naming
| Prefix | Use for |
|---|---|
| `fix/` | Bug fixes |
| `feat/` | New features |
| `docs/` | Documentation changes |
| `refactor/` | Code refactoring |
| `chore/` | Build, config, maintenance |
Examples: `fix/question-queue-ordering`, `feat/retry-tool-call`, `docs/contributing-guide`
### 3. Make Your Changes
```bash
# Install dependencies
npm install
# Run the dev server
npm run dev
# Run type checking
npm run typecheck --workspace @codenomad/ui
```
### 4. Commit
Write clear, descriptive commit messages. Explain **what** changed and **why**.
```bash
git add .
git commit -m "fix(ui): preserve question queue order when upserting duplicate requests
When a question arrives as a global entry and later resolves to a tool
part with a newer timestamp, the original enqueue time was lost, causing
the question to move behind newer entries and break interruption order."
```
### 5. Push and Create a PR
```bash
git push origin your-branch-name
```
Then open a pull request on GitHub targeting the `dev` branch.
**PR checklist:**
- [ ] Branch is based on latest `upstream/dev`
- [ ] One issue per PR (don't mix unrelated changes)
- [ ] Type checking passes: `npm run typecheck` (root) or the workspace-specific script matching your change area
- [ ] Tests pass (if applicable)
- [ ] PR description explains the change, includes relevant screenshots for UI changes, and links related issues when applicable
## Project Structure
| Package | Description |
|---|---|
| `packages/server` | Core logic & CLI — workspaces, OpenCode proxy, API, auth |
| `packages/ui` | SolidJS frontend — reactive UI components and stores |
| `packages/electron-app` | Electron desktop shell |
| `packages/tauri-app` | Tauri desktop shell (experimental) |
| `packages/opencode-plugin` | OpenCode plugin integration |
| `packages/cloudflare` | Cloudflare deployment adapters |
### Key UI Files
| Path | Purpose |
|---|---|
| `packages/ui/src/stores/session-events.ts` | SSE event handlers (idle, status, permissions, questions) |
| `packages/ui/src/stores/session-actions.ts` | User actions (send message, abort, revert, fork) |
| `packages/ui/src/stores/message-v2/` | Message store (v2 architecture) |
| `packages/ui/src/stores/instances.ts` | Instance management and interruption queues |
| `packages/ui/src/components/tool-call.tsx` | Tool call rendering |
| `packages/ui/src/components/message-block.tsx` | Message display blocks |
| `packages/ui/src/components/session/session-view.tsx` | Main session view |
| `packages/ui/src/lib/i18n/messages/` | Translation files (en, es, fr, ja, ru, he, zh-Hans) |
> For a comprehensive map of all six functional areas (server, UI, desktop, speech/audio, build, Cloudflare), SDK integration patterns, and feature traces, load the `codenomad-architecture-guide` skill:
> `.opencode/skills/codenomad-architecture-guide/SKILL.md`
### Styling
- Tokens: `packages/ui/src/styles/tokens.css`
- Utilities: `packages/ui/src/styles/utilities.css`
- Component styles: `packages/ui/src/styles/components/`, `packages/ui/src/styles/messaging/`, `packages/ui/src/styles/panels/`
- Keep style files under ~150 lines; split by component
### Internationalization (i18n)
- Use `useI18n()` in components, `tGlobal()` in stores
- Messages live in `packages/ui/src/lib/i18n/messages/<locale>/`
- When adding a string: add to `en/` first, then add the same key to every other locale
- Placeholders use `{name}` syntax (word characters only)
## Code Principles
- **KISS**: Keep modules narrowly scoped
- **DRY**: Share helpers before copy-pasting
- **Single responsibility**: Split files when concerns diverge
- **Composable primitives**: Prefer signals, hooks, utilities over deep inheritance
## Need Help?
- Check existing [issues](https://github.com/NeuralNomadsAI/CodeNomad/issues) and [PRs](https://github.com/NeuralNomadsAI/CodeNomad/pulls)
- Ask in the issue you're working on
- Review the [server documentation](packages/server/README.md) for CLI flags and configuration

View file

@ -1,11 +1,13 @@
# Winget release automation
CodeNomad publishes Winget updates from GitHub Releases with `.github/workflows/update-winget.yml`.
CodeNomad publishes Winget updates from the stable GitHub release pipeline. `.github/workflows/reusable-release.yml` now calls `.github/workflows/update-winget.yml` after the release assets finish uploading.
## Trigger
- Runs on `release.published`.
- Exits early for draft or prerelease releases.
- Runs as a reusable workflow from the stable release pipeline, after `build-and-upload` completes.
- Resolves the target release by tag through the GitHub API, then exits early for draft or prerelease releases.
- Can also be rerun manually with `workflow_dispatch` by supplying the stable release tag (and optionally the numeric release id).
- This avoids the old `release.published` trap where a release created by GitHub Actions with the default `GITHUB_TOKEN` does not fan out into a second workflow run.
- Waits for the expected stable Windows Tauri asset because the release record can exist before all assets finish uploading.
## Required configuration
@ -28,7 +30,7 @@ CodeNomad publishes Winget updates from GitHub Releases with `.github/workflows/
## Runtime flow
1. Resolve the release version from `github.event.release.tag_name`.
1. Resolve the target release by tag through the GitHub API, then derive the package version from the resolved release tag.
2. Poll the release API until exactly one uploaded asset matches the configured Windows Tauri asset template.
3. Download the matched asset once and compute a SHA-256 for logging and verification.
4. Verify the PAT owner matches `WINGET_FORK_OWNER` and that `${WINGET_FORK_OWNER}/winget-pkgs` is a fork of `microsoft/winget-pkgs`.
@ -38,3 +40,4 @@ CodeNomad publishes Winget updates from GitHub Releases with `.github/workflows/
- The workflow does not depend on a persistent local `winget-pkgs` clone.
- The current package in `winget-pkgs` uses the release ZIP with a nested NSIS installer, so the workflow targets the stable `CodeNomad-Tauri-windows-x64-{version}.zip` asset.
- If a maintainer publishes a release outside the standard release workflow, they should manually run `Update Winget` for that stable tag.

View file

@ -0,0 +1,102 @@
import assert from "node:assert/strict"
import { afterEach, beforeEach, describe, it } from "node:test"
import { setTimeout as delay } from "node:timers/promises"
import {
clearPendingDeltasForPart,
enqueueDelta,
flushPendingDeltasForMessage,
resetDeltaBufferForTests,
setFlushCallback,
} from "./delta-buffer.ts"
type DeltaBatch = Array<{ instanceId: string; messageId: string; partId: string; field: string; delta: string }>
describe("delta buffer", () => {
beforeEach(() => {
resetDeltaBufferForTests()
})
afterEach(() => {
resetDeltaBufferForTests()
})
it("concatenates matching deltas and flushes them once", async () => {
const flushed: DeltaBatch[] = []
setFlushCallback((batch) => flushed.push(batch))
enqueueDelta("instance-1", "message-1", "part-1", "text", "hello")
enqueueDelta("instance-1", "message-1", "part-1", "text", " world")
await delay(75)
assert.equal(flushed.length, 1)
assert.deepEqual(flushed[0], [
{ instanceId: "instance-1", messageId: "message-1", partId: "part-1", field: "text", delta: "hello world" },
])
})
it("clears pending deltas for a full part update before a stale timer flush", async () => {
const flushed: DeltaBatch[] = []
setFlushCallback((batch) => flushed.push(batch))
enqueueDelta("instance-1", "message-1", "part-1", "text", "stale")
clearPendingDeltasForPart("instance-1", "message-1", "part-1")
await delay(75)
assert.deepEqual(flushed, [])
})
it("flushes pending message deltas before applying message.updated", async () => {
const timerFlushes: DeltaBatch[] = []
const applied: Array<{ instanceId: string; delta: { messageId: string; partId: string; field: string; delta: string } }> = []
setFlushCallback((batch) => timerFlushes.push(batch))
enqueueDelta("instance-1", "message-1", "part-1", "text", "before update")
flushPendingDeltasForMessage("instance-1", "message-1", (instanceId, delta) => {
applied.push({ instanceId, delta })
})
await delay(75)
assert.deepEqual(applied, [
{
instanceId: "instance-1",
delta: { messageId: "message-1", partId: "part-1", field: "text", delta: "before update" },
},
])
assert.deepEqual(timerFlushes, [])
})
it("keeps clear and flush operations isolated by instance, message, and part", async () => {
const timerFlushes: DeltaBatch[] = []
const applied: Array<{ instanceId: string; delta: { messageId: string; partId: string; field: string; delta: string } }> = []
setFlushCallback((batch) => timerFlushes.push(batch))
enqueueDelta("instance-1", "message-1", "part-1", "text", "drop")
enqueueDelta("instance-1", "message-1", "part-2", "text", "same message")
enqueueDelta("instance-1", "message-2", "part-1", "text", "other message")
enqueueDelta("instance-2", "message-1", "part-1", "text", "other instance")
clearPendingDeltasForPart("instance-1", "message-1", "part-1")
flushPendingDeltasForMessage("instance-1", "message-1", (instanceId, delta) => {
applied.push({ instanceId, delta })
})
await delay(75)
assert.deepEqual(applied, [
{
instanceId: "instance-1",
delta: { messageId: "message-1", partId: "part-2", field: "text", delta: "same message" },
},
])
assert.deepEqual(timerFlushes, [
[
{ instanceId: "instance-1", messageId: "message-2", partId: "part-1", field: "text", delta: "other message" },
{ instanceId: "instance-2", messageId: "message-1", partId: "part-1", field: "text", delta: "other instance" },
],
])
})
})

View file

@ -0,0 +1,89 @@
/**
* Delta buffer for throttling SSE message.part.delta events.
*
* Accumulates text deltas in a 50ms window to reduce UI churn from
* high-frequency streaming chunks. Provides targeted flush/clear paths
* so full part-update or message-complete events always win over stale
* buffered deltas.
*/
const DELTA_FLUSH_INTERVAL = 50
const pendingDeltas = new Map<string, { instanceId: string; messageId: string; partId: string; field: string; delta: string }>()
let deltaFlushTimer: ReturnType<typeof setTimeout> | null = null
export function enqueueDelta(instanceId: string, messageId: string, partId: string, field: string, delta: string) {
const key = `${instanceId}:${messageId}:${partId}:${field}`
const existing = pendingDeltas.get(key)
const accumulated = existing ? existing.delta + delta : delta
pendingDeltas.set(key, { instanceId, messageId, partId, field, delta: accumulated })
if (deltaFlushTimer === null) {
deltaFlushTimer = setTimeout(flushDeltas, DELTA_FLUSH_INTERVAL)
}
}
export function clearPendingDeltasForPart(instanceId: string, messageId: string, partId: string) {
const keysToDelete: string[] = []
for (const key of pendingDeltas.keys()) {
if (key.startsWith(`${instanceId}:${messageId}:${partId}:`)) {
keysToDelete.push(key)
}
}
for (const key of keysToDelete) {
pendingDeltas.delete(key)
}
}
export function flushPendingDeltasForMessage(
instanceId: string,
messageId: string,
applyDelta: (instanceId: string, delta: { messageId: string; partId: string; field: string; delta: string }) => void
): void {
const prefix = `${instanceId}:${messageId}:`
const keysToFlush: string[] = []
for (const key of pendingDeltas.keys()) {
if (key.startsWith(prefix)) {
keysToFlush.push(key)
}
}
for (const key of keysToFlush) {
const pending = pendingDeltas.get(key)
if (pending) {
pendingDeltas.delete(key)
applyDelta(instanceId, {
messageId: pending.messageId,
partId: pending.partId,
field: pending.field,
delta: pending.delta,
})
}
}
}
export function setFlushCallback(
callback: (batch: Array<{ instanceId: string; messageId: string; partId: string; field: string; delta: string }>) => void
) {
// Store callback for flushDeltas to use
flushCallback = callback
}
export function resetDeltaBufferForTests() {
pendingDeltas.clear()
if (deltaFlushTimer !== null) {
clearTimeout(deltaFlushTimer)
deltaFlushTimer = null
}
flushCallback = null
}
let flushCallback: ((batch: Array<{ instanceId: string; messageId: string; partId: string; field: string; delta: string }>) => void) | null = null
function flushDeltas() {
deltaFlushTimer = null
if (pendingDeltas.size === 0) return
const batch = Array.from(pendingDeltas.values())
pendingDeltas.clear()
if (flushCallback) {
flushCallback(batch)
}
}

View file

@ -17,6 +17,12 @@ import type { MessageStatus } from "./message-v2/types"
import { getLogger } from "../lib/logger"
import { requestData } from "../lib/opencode-api"
import {
enqueueDelta,
clearPendingDeltasForPart,
flushPendingDeltasForMessage,
setFlushCallback,
} from "./delta-buffer"
import {
getPermissionId,
getPermissionKind,
@ -384,6 +390,12 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
}
// Clear any pending deltas for this part before applying the full part update.
// The part update contains the complete state from the server, so accumulated
// deltas would be stale and cause duplication if flushed later.
if (part.id) {
clearPendingDeltasForPart(instanceId, messageId, part.id)
}
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
handleConversationAssistantPartUpdated(instanceId, { ...part, sessionID: sessionId, messageID: messageId }, messageInfo)
@ -402,6 +414,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const messageId = typeof info.id === "string" ? info.id : undefined
if (!sessionId || !messageId) return
// Flush any pending deltas for this message before applying the update.
// Deltas are buffered for up to 50ms; if message.updated arrives before
// the buffer flushes, the message could be marked complete/error with
// stale text mutations still pending. Flushing first preserves the
// server's event ordering: all delta content is applied, then the
// message status/metadata update runs on the complete content.
flushPendingDeltasForMessage(instanceId, messageId, applyPartDeltaV2)
const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number }
const nextUpdated =
typeof timeInfo.end === "number" && timeInfo.end > 0
@ -453,12 +473,19 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
}
}
// Delta buffer callback setup
setFlushCallback((batch) => {
for (const { instanceId, messageId, partId, field, delta } of batch) {
applyPartDeltaV2(instanceId, { messageId, partId, field, delta })
}
})
function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent): void {
const props = event.properties
if (!props) return
const { messageID, partID, field, delta } = props
if (!messageID || !partID || !field || typeof delta !== "string") return
applyPartDeltaV2(instanceId, { messageId: messageID, partId: partID, field, delta })
enqueueDelta(instanceId, messageID, partID, field, delta)
}
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {