Skip to content
Merged
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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.22.1] - 2026-04-10

See [docs/releases/v0.22.1.md](docs/releases/v0.22.1.md) for full notes and [docs/releases/v0.22.1/assets.md](docs/releases/v0.22.1/assets.md) for release asset inventory.

### Added

- Add decision workspace contracts, projections, persistence tables, and WebSocket wiring groundwork.
- Add pending user input projections plus thread overview and detail queries.
- Add sidebar density controls, connection test controls, and expandable notification diagnostics with copy support.
- Add companion pairing contracts and mobile pairing stubs.
- Add stop support for pending git actions and external GitHub link opening from the preview popout.
- Add OpenClaw maintainer workflow skills.

### Changed

- Switch SME Claude flows to Claude Code CLI.
- Extract the OpenClaw gateway client with auth fallback and modernize the gateway handshake flow.
- Refresh theme tokens, default typography, and VS Code icon manifests.
- Preserve thread routes in desktop pop-out windows and widen preview viewport inputs.
- Render SME replies as markdown and replace the draft upload icon with a close action.

### Fixed

- Ignore expected redacted auth shutdown noise in Codex logs.
- Normalize React language ids for syntax highlighting.
- Defer the empty diff guard until after hook setup.
- Restore orchestration snapshot and thread-detail compatibility across shared contracts and WebSocket wiring.
- Backfill the pending user input projection table for already-upgraded state directories.
- Improve OpenClaw gateway handshake diagnostics and connection-stage reporting.
- Isolate CLI test state directories to avoid SQLite lock contention during release validation.
- Tune long user-message timeline height estimation so browser layout stays aligned with validated rendering.

## [0.22.0] - 2026-04-09

See [docs/releases/v0.22.0.md](docs/releases/v0.22.0.md) for full notes and [docs/releases/v0.22.0/assets.md](docs/releases/v0.22.0/assets.md) for release asset inventory.
Expand Down Expand Up @@ -658,3 +690,4 @@ First public version tag. See [docs/releases/v0.0.1.md](docs/releases/v0.0.1.md)
[0.20.0]: https://github.com/OpenKnots/okcode/releases/tag/v0.20.0
[0.21.0]: https://github.com/OpenKnots/okcode/releases/tag/v0.21.0
[0.22.0]: https://github.com/OpenKnots/okcode/releases/tag/v0.22.0
[0.22.1]: https://github.com/OpenKnots/okcode/releases/tag/v0.22.1
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@okcode/desktop",
"version": "0.22.0",
"version": "0.22.1",
"private": true,
"main": "dist-electron/main.js",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "0.22.0"
versionName "0.22.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.22.0;
MARKETING_VERSION = 0.22.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.openknots.okcode.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand All @@ -331,7 +331,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.22.0;
MARKETING_VERSION = 0.22.1;
PRODUCT_BUNDLE_IDENTIFIER = com.openknots.okcode.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@okcode/mobile",
"version": "0.22.0",
"version": "0.22.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "okcodes",
"version": "0.22.0",
"version": "0.22.1",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
9 changes: 5 additions & 4 deletions apps/server/src/decision/Services/DecisionProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ export interface DecisionProjectionShape {
readonly consultation: DecisionConsultation;
readonly questions: ReadonlyArray<DecisionConsultationQuestion>;
}) => Effect.Effect<void, DecisionWorkspaceServiceError>;
readonly getConsultation: (input: {
readonly consultationId: string;
}) => Effect.Effect<
{ consultation: DecisionConsultation; questions: ReadonlyArray<DecisionConsultationQuestion> } | null,
readonly getConsultation: (input: { readonly consultationId: string }) => Effect.Effect<
{
consultation: DecisionConsultation;
questions: ReadonlyArray<DecisionConsultationQuestion>;
} | null,
DecisionWorkspaceServiceError
>;
readonly listConsultationsByCaseId: (input: {
Expand Down
21 changes: 16 additions & 5 deletions apps/server/src/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { mkdtempSync } from "node:fs";
import * as Http from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it, vi } from "@effect/vitest";
import * as ConfigProvider from "effect/ConfigProvider";
Expand All @@ -17,6 +20,7 @@ import { Server, type ServerShape } from "./wsServer";
const start = vi.fn(() => undefined);
const stop = vi.fn(() => undefined);
let resolvedConfig: ServerConfigShape | null = null;
let testWorkspaceRoot = "";
const serverStart = Effect.acquireRelease(
Effect.gen(function* () {
resolvedConfig = yield* ServerConfig;
Expand All @@ -29,11 +33,17 @@ const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)

// Shared service layer used by this CLI test suite.
const testLayer = Layer.mergeAll(
Layer.succeed(CliConfig, {
cwd: "/tmp/t3-test-workspace",
fixPath: Effect.void,
resolveStaticDir: Effect.undefined,
} satisfies CliConfigShape),
Layer.effect(
CliConfig,
Effect.sync(
() =>
({
cwd: testWorkspaceRoot,
fixPath: Effect.void,
resolveStaticDir: Effect.undefined,
}) satisfies CliConfigShape,
),
),
Layer.succeed(NetService, {
canListenOnHost: () => Effect.succeed(true),
isPortAvailableOnLoopback: () => Effect.succeed(true),
Expand Down Expand Up @@ -74,6 +84,7 @@ const runCli = (
beforeEach(() => {
vi.clearAllMocks();
resolvedConfig = null;
testWorkspaceRoot = mkdtempSync(join(tmpdir(), "okcode-main-test-"));
start.mockImplementation(() => undefined);
stop.mockImplementation(() => undefined);
findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred));
Expand Down
76 changes: 53 additions & 23 deletions apps/server/src/openclawGatewayTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ interface MutableGatewayDiagnostics {
hints: string[];
}

interface RunOpenclawGatewayTestOptions {
readonly stateDir?: string | undefined;
}

interface OpenClawGatewayErrorLike {
readonly message: string;
readonly code?: string;
Expand Down Expand Up @@ -75,10 +79,6 @@ function toMessage(cause: unknown, fallback: string): string {
return fallback;
}

function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

function applyGatewayError(
diagnostics: MutableGatewayDiagnostics,
error: OpenClawGatewayErrorLike | undefined,
Expand All @@ -87,24 +87,40 @@ function applyGatewayError(
return;
}

diagnostics.gatewayErrorCode = error.code;
if (typeof error.code === "string") {
diagnostics.gatewayErrorCode = error.code;
}
const details = error.details ?? {};
diagnostics.gatewayErrorDetailCode = typeof details.code === "string" ? details.code : undefined;
diagnostics.gatewayErrorDetailReason =
typeof details.reason === "string" ? details.reason : undefined;
diagnostics.gatewayRecommendedNextStep =
typeof details.recommendedNextStep === "string" ? details.recommendedNextStep : undefined;
diagnostics.gatewayCanRetryWithDeviceToken =
typeof details.canRetryWithDeviceToken === "boolean"
? details.canRetryWithDeviceToken
: undefined;
if (typeof details.code === "string") {
diagnostics.gatewayErrorDetailCode = details.code;
}
if (typeof details.reason === "string") {
diagnostics.gatewayErrorDetailReason = details.reason;
}
if (typeof details.recommendedNextStep === "string") {
diagnostics.gatewayRecommendedNextStep = details.recommendedNextStep;
}
if (typeof details.canRetryWithDeviceToken === "boolean") {
diagnostics.gatewayCanRetryWithDeviceToken = details.canRetryWithDeviceToken;
}
}

function pushUnique(items: string[], value: string): void {
if (items.includes(value) || items.length >= MAX_CAPTURED_NOTIFICATIONS) return;
items.push(value);
}

function formatGatewayFailureDetail(
detail: string,
diagnostics: Pick<MutableGatewayDiagnostics, "gatewayErrorDetailCode">,
): string {
const code = diagnostics.gatewayErrorDetailCode;
if (!code || detail.includes(code)) {
return detail;
}
return `${detail} (${code})`;
}

function isLoopbackHost(host: string): boolean {
const normalized = host.toLowerCase();
return (
Expand Down Expand Up @@ -245,11 +261,6 @@ async function probeHealth(parsedUrl: URL): Promise<GatewayHealthProbe> {
}
}

function formatSocketClose(code: number | undefined, reason: string | undefined): string | null {
if (code === undefined) return null;
return reason && reason.length > 0 ? `code ${code}: ${reason}` : `code ${code}`;
}

function buildHints(
parsedUrl: URL,
diagnostics: Pick<
Expand Down Expand Up @@ -413,6 +424,7 @@ export async function runOpenclawGatewayTest(
const steps: TestOpenclawGatewayStep[] = [];
const diagnostics: MutableGatewayDiagnostics = createDiagnostics();
let parsedUrlForHints: URL | null = null;
let connection: Awaited<ReturnType<typeof connectOpenClawGateway>> | undefined;

const pushStep = (
name: string,
Expand Down Expand Up @@ -504,11 +516,9 @@ export async function runOpenclawGatewayTest(
diagnostics.hostKind = classifyGatewayHost(parsedUrl.hostname, diagnostics.resolvedAddresses);

const connectStart = Date.now();
let connection: Awaited<ReturnType<typeof connectOpenClawGateway>> | undefined;
try {
connection = await connectOpenClawGateway({
gatewayUrl,
stateDir: options?.stateDir,
sessionKey: "okcode:gateway-test",
role: "operator",
scopes: [...OPENCLAW_OPERATOR_SCOPES],
Expand All @@ -525,7 +535,8 @@ export async function runOpenclawGatewayTest(
},
userAgent: `okcode/${serverBuildInfo.version}`,
locale: Intl.DateTimeFormat().resolvedOptions().locale || "en-US",
password: sharedSecret,
...(options?.stateDir ? { stateDir: options.stateDir } : {}),
...(sharedSecret ? { password: sharedSecret } : {}),
onEvent: (event) => {
pushUnique(diagnostics.observedNotifications, event.event);
},
Expand All @@ -543,8 +554,27 @@ export async function runOpenclawGatewayTest(
cause instanceof Error
? (cause as Error & { readonly gatewayError?: OpenClawGatewayErrorLike }).gatewayError
: undefined;
const connectionStage =
cause instanceof Error
? (cause as Error & { readonly openClawConnectionStage?: "websocket" | "handshake" })
.openClawConnectionStage
: undefined;
applyGatewayError(diagnostics, gatewayError);
const detail = toMessage(cause, "Connection failed.");
const detail = formatGatewayFailureDetail(
toMessage(cause, "Connection failed."),
diagnostics,
);
if (connectionStage === "handshake") {
pushStep(
"WebSocket connect",
"pass",
Date.now() - connectStart,
`Connected in ${Date.now() - connectStart}ms`,
);
applyHealthProbe(await healthPromise);
pushStep("Gateway handshake", "fail", 0, detail);
return finalize(false, detail, "Gateway handshake");
}
pushStep("WebSocket connect", "fail", Date.now() - connectStart, detail);
applyHealthProbe(await healthPromise);
return finalize(false, detail, "WebSocket connect");
Expand Down
30 changes: 11 additions & 19 deletions apps/server/src/orchestration/Layers/ProjectionOverviewQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,29 +31,21 @@ import {
import { ProjectionState } from "../../persistence/Services/ProjectionState.ts";
import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts";
import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";

const ProjectionProjectOverviewRow = ProjectionProject.mapFields({
const ProjectionProjectOverviewRow = Schema.Struct({
...ProjectionProject.fields,
scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
}).pipe(
Schema.extend(
Schema.Struct({
activeThreadCount: NonNegativeInt,
}),
),
);
activeThreadCount: NonNegativeInt,
});

const ProjectionThreadOverviewRow = ProjectionThread.pipe(
Schema.extend(
Schema.Struct({
lastUserMessageAt: Schema.NullOr(IsoDateTime),
pendingApprovalCount: NonNegativeInt,
pendingUserInputCount: NonNegativeInt,
}),
),
);
const ProjectionThreadOverviewRow = Schema.Struct({
...ProjectionThread.fields,
lastUserMessageAt: Schema.NullOr(IsoDateTime),
pendingApprovalCount: NonNegativeInt,
pendingUserInputCount: NonNegativeInt,
});

const ProjectionLatestTurnDbRowSchema = Schema.Struct({
threadId: ProjectionThread.fields.threadId,
Expand Down Expand Up @@ -419,7 +411,7 @@ const makeProjectionOverviewQuery = Effect.gen(function* () {
projects,
threads,
updatedAt:
updatedAtCandidates.sort((left, right) =>
updatedAtCandidates.toSorted((left, right) =>
left < right ? 1 : left > right ? -1 : 0,
)[0] ?? new Date(0).toISOString(),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
payload: {
threadId,
deletedAt: now,
reason: "manual",
},
});

Expand Down Expand Up @@ -1179,6 +1180,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
payload: {
threadId: ThreadId.makeUnsafe(".."),
deletedAt: now,
reason: "manual",
},
});

Expand Down
Loading
Loading