Compare commits

...
10 Commits
Author SHA1 Message Date
Haze bc37f7fabc 0.3.10-beta.5 2026-04-20 18:44:00 +08:00
Haze ec4007cd23 chore(release): update version to 0.3.10-beta.4 and enhance release validation
- Bump version in package.json to 0.3.10-beta.4.
- Add a new GitHub Actions job to validate that the version in package.json matches the release tag.
- Introduce scripts for versioning and release validation to streamline the release process.
2026-04-20 18:43:53 +08:00
Haze 78ab21d8e2 0.3.10-beta.3 2026-04-20 18:37:45 +08:00
Haze f6d7fda60a refactor(gateway): remove phase completion timer logic and update run completion handling
Eliminate the phase completion timer and its associated logic from the Gateway. The handling of run completion is now solely based on Gateway phase events and streaming final events. This change simplifies the code and ensures that the state transitions are more reliable, as run completion is no longer inferred from the timer.

Additionally, update the runtime send actions to finalize the sending state immediately after the chat.send RPC completes, ensuring accurate state management during agent conversations.
2026-04-20 18:37:19 +08:00
HazeandClaude Opus 4.6 ef51a8bbbf fix(chat): add grace period for Gateway phase completion events
The Gateway sends phase "end" after each tool-execution round (sub-run),
not just when the entire conversation finishes. This caused sending=false
between tool rounds, breaking the thinking indicator and input state.

Add a 5-second grace timer: on phase "end", delay sending=false. If a
new streaming event, "started" phase, or chat data arrives within the
window, the timer is cancelled and sending stays true. Only if the
grace period expires with no new activity does the run finalize.

Also: remove loadHistory finalize logic entirely — run completion is
now handled exclusively by Gateway phase events (with grace) and
streaming final events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:30:46 +08:00
HazeandClaude Opus 4.6 7d955fc607 fix(chat): remove loadHistory run completion inference, rely on Gateway events
loadHistory repeatedly set sending=false during server-side tool execution
by incorrectly inferring run completion from message content.

Run completion is now ONLY signalled by:
1. Gateway's phase 'completed' event (gateway.ts)
2. Streaming 'final' event (runtime-event-handlers.ts)
3. Safety timeout after 90s of no events

Also: fully controlled graph expanded prop, stable key, card.active
decoupled from streamingReplyText, suppressThinking prop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:20:39 +08:00
HazeandClaude Opus 4.6 94f5ae2799 fix(chat): simplify tool phase detection and fix stale cache reply leak
- Revert hasCompletedToolPhase to simple check (segmentHasTools only).
  The lastAssistantHasNoTools guard was too restrictive: during reply
  streaming the last assistant in history still has tool_use (reply only
  exists in streamingMessage). The intermediate-narration edge case is
  already handled by stripProcessMessagePrefix producing empty
  trimmedReplyText, causing graceful fallback to buildSteps(false).

- Fix stale graph cache: filter out stream-generated message steps
  (id prefix 'stream-message') instead of brittle exact-match. These
  steps contain accumulated narration+reply text from streaming phase
  that should not persist after completion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:17:57 +08:00
HazeandClaude Opus 4.6 12d7c8ade0 fix(chat): refine tool phase detection and clean stale reply from graph cache
- hasCompletedToolPhase now checks that the last assistant message in the
  segment has no tool_use blocks, preventing false positives during
  intermediate tool rounds that would suppress the trailing thinking indicator
- Filter reply text from cached graph steps when a completed run falls
  back to the step cache, preventing the final response from appearing
  inside the graph when expanding after completion
- Remove debug logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:09:20 +08:00
HazeandClaude Opus 4.6 74f6dd0236 fix(chat): exclude tool-result user messages from run segmentation
Gateway history contains `role: 'user'` messages that are actually
tool-result wrappers (Anthropic API format). These were incorrectly
treated as run boundaries in nextUserMessageIndexes, causing:
- isLatestOpenRun=false during tool execution → graph collapses
- Run split into multiple segments → incorrect step attribution

Add isRealUserMessage() that detects tool-result wrappers by checking
if all content blocks are type 'tool_result', and use it in both
nextUserMessageIndexes computation and userRunCards filtering.

Also remove debug logging from previous iterations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 17:00:00 +08:00
HazeandClaude Opus 4.6 f8c6643b38 fix(chat): prevent graph collapse during streaming and strip thinking from reply bubble
- Prevent execution graph from auto-collapsing while reply is still
  streaming by excluding from autoCollapsedRunKeys and keeping
  expanded=true via controlled prop
- Strip thinking blocks from the streaming ChatMessage when the reply
  renders as a separate bubble, so thinking content doesn't duplicate
  alongside the response text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:52:26 +08:00
9 changed files with 212 additions and 64 deletions
+11
View File
@@ -18,7 +18,18 @@ permissions:
actions: read
jobs:
# Fails fast on tag pushes if package.json "version" does not match the tag.
validate-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Assert tag matches package.json
run: node scripts/assert-tag-matches-package.mjs
release:
needs: validate-release
strategy:
matrix:
include:
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawx",
"version": "0.3.10-beta.2",
"version": "0.3.10-beta.5",
"pnpm": {
"onlyBuiltDependencies": [
"@discordjs/opus",
@@ -62,9 +62,12 @@
"package:win": "pnpm run prep:win-binaries && pnpm run package && electron-builder --win --publish never",
"package:linux": "pnpm run package && electron-builder --linux --publish never",
"release": "pnpm run uv:download && pnpm run package && electron-builder --publish always",
"version": "node scripts/assert-release-version.mjs",
"version:patch": "pnpm version patch",
"version:minor": "pnpm version minor",
"version:major": "pnpm version major",
"version:prerelease-beta": "pnpm version prerelease --preid=beta",
"release:validate": "node scripts/assert-tag-matches-package.mjs",
"postversion": "git push && git push --tags"
},
"dependencies": {
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* npm/pnpm `version` lifecycle hook: runs after package.json is bumped, before
* `git tag`. Aborts if the target tag already exists so we never fail late on
* `fatal: tag 'vX.Y.Z' already exists`.
*/
import { readFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
function readPackageVersion() {
const raw = readFileSync(join(root, 'package.json'), 'utf8');
return JSON.parse(raw).version;
}
const version = process.env.npm_package_version || readPackageVersion();
const tag = `v${version}`;
function localTagExists(t) {
try {
execSync(`git rev-parse -q --verify refs/tags/${t}`, { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
if (localTagExists(tag)) {
console.error(`
Release version check failed: git tag ${tag} already exists locally.
You cannot run \`pnpm version …\` for ${version} until that tag is gone or the
version is bumped to a value that does not yet have a tag.
Typical fixes:
• Use the next prerelease explicitly, e.g. \`pnpm version 0.3.10-beta.4\`
• Or delete only if you are sure it was created by mistake: \`git tag -d ${tag}\`
`);
process.exit(1);
}
console.log(`Release version OK: tag ${tag} is not present locally yet.`);
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
/**
* CI / global release sanity: when building from a version tag, the root
* package.json "version" must match the tag (without the leading "v").
*
* Exits 0 when GITHUB_REF is not refs/tags/v* (e.g. branch builds, PRs).
*/
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const ref = process.env.GITHUB_REF || '';
if (!ref.startsWith('refs/tags/v')) {
console.log(
`[assert-tag-matches-package] Skip: GITHUB_REF is not a version tag (${ref || '(empty)'})`,
);
process.exit(0);
}
const tagVersion = ref.slice('refs/tags/v'.length);
const pkgVersion = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
if (tagVersion !== pkgVersion) {
console.error(
`[assert-tag-matches-package] Mismatch: git tag is "${tagVersion}" but package.json version is "${pkgVersion}".`,
);
console.error(
'Push a commit that sets package.json "version" to match the tag before cutting the release.',
);
process.exit(1);
}
console.log(`[assert-tag-matches-package] OK: tag v${tagVersion} matches package.json.`);
+4 -1
View File
@@ -8,6 +8,8 @@ interface ExecutionGraphCardProps {
agentLabel: string;
steps: TaskStep[];
active: boolean;
/** Hide the trailing "Thinking ..." indicator even when active. */
suppressThinking?: boolean;
/**
* When provided, the card becomes fully controlled: the parent owns the
* expand state (e.g. to persist across remounts) and toggling goes through
@@ -149,6 +151,7 @@ export function ExecutionGraphCard({
agentLabel,
steps,
active,
suppressThinking = false,
expanded: controlledExpanded,
onExpandedChange,
}: ExecutionGraphCardProps) {
@@ -175,7 +178,7 @@ export function ExecutionGraphCard({
const toolCount = steps.filter((step) => step.kind === 'tool').length;
const processCount = steps.length - toolCount;
const shouldShowTrailingThinking = active;
const shouldShowTrailingThinking = active && !suppressThinking;
if (!expanded) {
return (
+81 -30
View File
@@ -187,11 +187,25 @@ export function Chat() {
const isEmpty = messages.length === 0 && !sending;
const subagentCompletionInfos = messages.map((message) => parseSubagentCompletionInfo(message));
// Build an index of the *next* real user message after each position.
// Gateway history may contain `role: 'user'` messages that are actually
// tool-result wrappers (Anthropic API format). These must NOT split
// the run into multiple segments — only genuine user-authored messages
// should act as run boundaries.
const isRealUserMessage = (msg: RawMessage): boolean => {
if (msg.role !== 'user') return false;
const content = msg.content;
if (!Array.isArray(content)) return true;
// If every block in the content is a tool_result, this is a Gateway
// tool-result wrapper, not a real user message.
const blocks = content as Array<{ type?: string }>;
return blocks.length === 0 || !blocks.every((b) => b.type === 'tool_result');
};
const nextUserMessageIndexes = new Array<number>(messages.length).fill(-1);
let nextUserMessageIndex = -1;
for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
nextUserMessageIndexes[idx] = nextUserMessageIndex;
if (messages[idx].role === 'user' && !subagentCompletionInfos[idx]) {
if (isRealUserMessage(messages[idx]) && !subagentCompletionInfos[idx]) {
nextUserMessageIndex = idx;
}
}
@@ -202,7 +216,7 @@ export function Chat() {
const foldedNarrationIndices = new Set<number>();
const userRunCards: UserRunCard[] = messages.flatMap((message, idx) => {
if (message.role !== 'user' || subagentCompletionInfos[idx]) return [];
if (!isRealUserMessage(message) || subagentCompletionInfos[idx]) return [];
const runKey = message.id
? `msg-${message.id}`
@@ -266,7 +280,9 @@ export function Chat() {
// 2. `allToolsCompleted` — all entries in streamingTools are completed
// 3. `hasCompletedToolPhase` — historical messages (loaded by the poll)
// contain tool_use blocks, meaning the Gateway executed tools
// server-side without sending streaming tool events to the client
// server-side without sending streaming tool events to the client.
// During intermediate narration (before reply), stripProcessMessagePrefix
// will produce an empty trimmedReplyText, so the graph stays active.
const allToolsCompleted = streamingTools.length > 0 && !hasRunningStreamToolStatus;
const hasCompletedToolPhase = segmentMessages.some((msg) =>
msg.role === 'assistant' && extractToolUse(msg).length > 0,
@@ -309,6 +325,13 @@ export function Chat() {
}
const cached = graphStepCache[runKey];
if (!cached) return [];
// The cache was captured during streaming and may contain stream-
// generated message steps that include accumulated narration + reply
// text. Strip these out — historical message steps (from messages[])
// will be properly recomputed on the next render with fresh data.
const cleanedSteps = cached.steps.filter(
(s) => !(s.kind === 'message' && s.id.startsWith('stream-message')),
);
return [{
triggerIndex: idx,
replyIndex: cached.replyIndex,
@@ -316,8 +339,8 @@ export function Chat() {
agentLabel: cached.agentLabel,
sessionLabel: cached.sessionLabel,
segmentEnd: nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1,
steps: cached.steps,
messageStepTexts: getPrimaryMessageStepTexts(cached.steps),
steps: cleanedSteps,
messageStepTexts: getPrimaryMessageStepTexts(cleanedSteps),
streamingReplyText: null,
}];
}
@@ -345,10 +368,17 @@ export function Chat() {
foldedNarrationIndices.add(idx + 1 + offset);
}
// The graph should stay "active" (expanded, can show trailing thinking)
// for the entire duration of the run — not just until a streaming reply
// appears. Tying active to streamingReplyText caused a flicker: a brief
// active→false→true transition collapsed the graph via ExecutionGraphCard's
// uncontrolled path before the controlled `expanded` override could kick in.
const cardActive = isLatestOpenRun;
return [{
triggerIndex: idx,
replyIndex,
active: isLatestOpenRun && streamingReplyText == null,
active: cardActive,
agentLabel: segmentAgentLabel,
sessionLabel: segmentSessionLabel,
segmentEnd: nextUserIndex === -1 ? messages.length - 1 : nextUserIndex - 1,
@@ -378,12 +408,12 @@ export function Chat() {
const autoCollapsedRunKeys = useMemo(() => {
const keys = new Set<string>();
for (const card of userRunCards) {
// Auto-collapse once the reply is visible — either the streaming
// reply bubble is already rendering (streamingReplyText != null)
// or the run finished and we have a reply text override.
const hasStreamingReply = card.streamingReplyText != null;
const hasHistoricalReply = card.replyIndex != null && replyTextOverrides.has(card.replyIndex);
const shouldCollapse = hasStreamingReply || hasHistoricalReply;
// Only auto-collapse after the run is fully complete — not while
// the reply is still streaming, otherwise the graph jumps to a
// collapsed summary mid-stream.
const isStillStreaming = card.streamingReplyText != null;
const shouldCollapse = !isStillStreaming
&& (card.replyIndex != null && replyTextOverrides.has(card.replyIndex));
if (!shouldCollapse) continue;
const triggerMsg = messages[card.triggerIndex];
const runKey = triggerMsg?.id
@@ -492,17 +522,22 @@ export function Chat() {
? `msg-${triggerMsg.id}`
: `${currentSessionKey}:trigger-${card.triggerIndex}`;
const userOverride = graphExpandedOverrides[runKey];
// Always use the controlled expanded prop instead of
// relying on ExecutionGraphCard's uncontrolled state.
// Uncontrolled state is lost on remount (key changes
// when loadHistory replaces message ids), causing
// spurious collapse. The controlled prop survives
// remounts because it's computed fresh each render.
const expanded = userOverride != null
? userOverride
: autoCollapsedRunKeys.has(runKey)
? false
: undefined;
: !autoCollapsedRunKeys.has(runKey);
return (
<ExecutionGraphCard
key={`graph-${runKey}`}
key={`graph-${currentSessionKey}:${card.triggerIndex}`}
agentLabel={card.agentLabel}
steps={card.steps}
active={card.active}
suppressThinking={card.streamingReplyText != null}
expanded={expanded}
onExpandedChange={(next) =>
setGraphExpandedOverrides((prev) => ({ ...prev, [runKey]: next }))
@@ -514,21 +549,37 @@ export function Chat() {
);
})}
{/* Streaming message */}
{shouldRenderStreaming && !hasActiveExecutionGraph && (
{/* Streaming message — render when reply text is separated from graph,
OR when there's streaming content without an active graph */}
{shouldRenderStreaming && (streamingReplyText != null || !hasActiveExecutionGraph) && (
<ChatMessage
message={(streamMsg
? {
...(streamMsg as Record<string, unknown>),
role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'],
content: streamMsg.content ?? streamText,
timestamp: streamMsg.timestamp ?? streamingTimestamp,
}
: {
role: 'assistant',
content: streamText,
timestamp: streamingTimestamp,
}) as RawMessage}
message={(() => {
const base = streamMsg
? {
...(streamMsg as Record<string, unknown>),
role: (typeof streamMsg.role === 'string' ? streamMsg.role : 'assistant') as RawMessage['role'],
content: streamMsg.content ?? streamText,
timestamp: streamMsg.timestamp ?? streamingTimestamp,
}
: {
role: 'assistant' as const,
content: streamText,
timestamp: streamingTimestamp,
};
// When the reply renders as a separate bubble, strip
// thinking blocks from the message — they belong to
// the execution phase and are already omitted from
// the graph via omitLastStreamingMessageSegment.
if (streamingReplyText != null && Array.isArray(base.content)) {
return {
...base,
content: (base.content as Array<{ type?: string }>).filter(
(block) => block.type !== 'thinking',
),
} as RawMessage;
}
return base as RawMessage;
})()}
textOverride={streamingReplyText ?? undefined}
isStreaming
streamingTools={streamingReplyText != null ? [] : streamingTools}
+12 -21
View File
@@ -2,12 +2,10 @@ import { invokeIpc } from '@/lib/api-client';
import { hostApiFetch } from '@/lib/host-api';
import { useGatewayStore } from '@/stores/gateway';
import {
clearHistoryPoll,
enrichWithCachedImages,
enrichWithToolResultFiles,
getLatestOptimisticUserMessage,
getMessageText,
hasNonToolAssistantContent,
isInternalMessage,
isToolResultRole,
loadMissingPreviews,
@@ -160,6 +158,18 @@ export function createHistoryActions(
return toMs(msg.timestamp) >= userMsTs;
};
// If we're sending but haven't received streaming events, check
// whether the loaded history reveals assistant activity (tool calls,
// narration, etc.). Setting pendingFinal surfaces the execution
// graph / activity indicator in the UI.
//
// Note: we intentionally do NOT set sending=false here. Run
// completion is exclusively signalled by the Gateway's phase
// 'completed' event (handled in gateway.ts) or by receiving a
// 'final' streaming event (handled in runtime-event-handlers.ts).
// Attempting to infer completion from message history is fragile
// and leads to premature sending=false during server-side tool
// execution.
if (isSendingNow && !pendingFinal) {
const hasRecentAssistantActivity = [...filteredMessages].reverse().some((msg) => {
if (msg.role !== 'assistant') return false;
@@ -169,25 +179,6 @@ export function createHistoryActions(
set({ pendingFinal: true });
}
}
// If pendingFinal, check whether the AI produced a final text response.
// Only finalize when the candidate is the very last message in the
// history — intermediate assistant messages (narration + tool_use) are
// followed by tool-result messages and must NOT be treated as the
// completed response, otherwise `pendingFinal` is cleared too early
// and the streaming reply bubble never renders.
if (pendingFinal || get().pendingFinal) {
const recentAssistant = [...filteredMessages].reverse().find((msg) => {
if (msg.role !== 'assistant') return false;
if (!hasNonToolAssistantContent(msg)) return false;
return isAfterUserMsg(msg);
});
const lastMsg = filteredMessages[filteredMessages.length - 1];
if (recentAssistant && lastMsg === recentAssistant) {
clearHistoryPoll();
set({ sending: false, activeRunId: null, pendingFinal: false });
}
}
return true;
};
+13 -2
View File
@@ -223,8 +223,19 @@ export function createRuntimeSendActions(set: ChatSet, get: ChatGet): Pick<Runti
if (!result.success) {
clearHistoryPoll();
set({ error: result.error || 'Failed to send message', sending: false });
} else if (result.result?.runId) {
set({ activeRunId: result.result.runId });
} else {
if (result.result?.runId) {
set({ activeRunId: result.result.runId });
}
// The chat.send RPC blocks until the entire agent conversation
// finishes. If sending is still true (streaming events haven't
// finalized it yet), finalize now — this is the authoritative
// signal that the run is complete.
if (get().sending) {
clearHistoryPoll();
set({ sending: false, activeRunId: null, pendingFinal: false, lastUserMessageAt: null });
get().loadHistory(true);
}
}
} catch (err) {
clearHistoryPoll();
+7 -9
View File
@@ -128,6 +128,8 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec
const hasChatData = (p.state ?? data.state) || (p.message ?? data.message);
if (hasChatData) {
// Any streaming data cancels the phase-completion grace timer — the
// run is still producing output (or a new sub-run has started).
const normalizedEvent: Record<string, unknown> = {
...data,
runId: p.runId ?? data.runId,
@@ -188,15 +190,11 @@ function handleGatewayNotification(notification: { method?: string; params?: Rec
if (matchesCurrentSession || matchesActiveRun) {
maybeLoadHistory(state);
}
if ((matchesCurrentSession || matchesActiveRun) && state.sending) {
useChatStore.setState({
sending: false,
activeRunId: null,
pendingFinal: false,
lastUserMessageAt: null,
error: null,
});
}
// Note: we do NOT set sending=false here. The Gateway sends
// phase "end" after each tool-execution round (sub-run), not only
// when the entire conversation finishes. Run completion is
// determined by the chat.send RPC returning (runtime-send-actions)
// or a streaming "final" event with output (runtime-event-handlers).
})
.catch(() => {});
}