mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-10 00:14:27 +00:00
Merge branch 'NeuralNomadsAI:dev' into bugfix/tabCloseBug
This commit is contained in:
commit
bbfc2ace22
7 changed files with 465 additions and 12 deletions
12
.github/workflows/reusable-release.yml
vendored
12
.github/workflows/reusable-release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
80
.github/workflows/update-winget.yml
vendored
80
.github/workflows/update-winget.yml
vendored
|
|
@ -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
154
CONTRIBUTING.md
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
102
packages/ui/src/stores/delta-buffer.test.ts
Normal file
102
packages/ui/src/stores/delta-buffer.test.ts
Normal 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" },
|
||||
],
|
||||
])
|
||||
})
|
||||
})
|
||||
89
packages/ui/src/stores/delta-buffer.ts
Normal file
89
packages/ui/src/stores/delta-buffer.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue