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
18 changes: 16 additions & 2 deletions packages/mtmharness/src/client/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type Registered = {
component: unknown;
};

function clientBench(): { registered: Registered[]; cleanups: Array<() => void | Promise<void>> } {
function clientBench(loopback = true): { registered: Registered[]; cleanups: Array<() => void | Promise<void>> } {
const registered: Registered[] = [];
const cleanups: Array<() => void | Promise<void>> = [];
const snapshot = createDemoRegistry().getSnapshot();
Expand All @@ -36,7 +36,7 @@ function clientBench(): { registered: Registered[]; cleanups: Array<() => void |
};
const ctx = {
get(name: string) {
if (name === "connection") return { rpc: { call: async () => ({ ok: true, value: snapshot }) } };
if (name === "connection") return { isLoopback: loopback, rpc: { call: async () => ({ ok: true, value: snapshot }) } };
throw new Error("unexpected service: " + name);
},
provide() {},
Expand Down Expand Up @@ -146,6 +146,20 @@ describe("mtmharness browser half", () => {
expect(inject).toEqual(["slots", "connection", "locale", "settingsScope"]);
});

it("only exposes update actions for loopback connections", () => {
const local = clientBench(true);
const localCard = local.registered.find((entry) => entry.options.key === "mtm-coding");
const localFace = (localCard?.options.inject as (() => { hooks: { mtmCodingCard: { getSnapshot: () => { update: { available: boolean } } } } }) | undefined)?.();
expect(localFace?.hooks.mtmCodingCard.getSnapshot().update.available).toBe(true);
for (const cleanup of local.cleanups.reverse()) void cleanup();

const remote = clientBench(false);
const remoteCard = remote.registered.find((entry) => entry.options.key === "mtm-coding");
const remoteFace = (remoteCard?.options.inject as (() => { hooks: { mtmCodingCard: { getSnapshot: () => { update: { available: boolean } } } } }) | undefined)?.();
expect(remoteFace?.hooks.mtmCodingCard.getSnapshot().update.available).toBe(false);
for (const cleanup of remote.cleanups.reverse()) void cleanup();
});

it("fails clearly when the Client connection service is unavailable", () => {
const cleanups: Array<() => void | Promise<void>> = [];
const ctx = {
Expand Down
20 changes: 20 additions & 0 deletions packages/mtmharness/src/features/coding/client/MtmCodingCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function MtmCodingCard(props: MtmCodingCardProps) {
const disabled = !state.writable;
const mode = state.fields.ponytailMode;
const rtkMode = state.fields.rtkMode;
const update = state.update;
return (
<li style={cardStyle}>
<button type="button" style={headerStyle} aria-expanded={open} aria-label={t(open ? "hide" : "show")} onClick={() => { setOpen(value => !value); }}>
Expand Down Expand Up @@ -133,6 +134,25 @@ export function MtmCodingCard(props: MtmCodingCardProps) {
</div>
<BooleanField t={t} label="rtkAutoInstall" hint="rtkAutoInstallHint" field={state.fields.rtkAutoInstall} disabled={disabled} onChange={(value) => { props.edit("rtkAutoInstall", String(value)); }} onReset={() => { props.resetField("rtkAutoInstall"); }} />
<TextField t={t} label="rtkCommand" hint="rtkCommandHint" field={state.fields.rtkCommand} disabled={disabled} onChange={(value) => { props.edit("rtkCommand", value); }} onReset={() => { props.resetField("rtkCommand"); }} />
{update.available ? (
<div style={fieldStyle}>
<strong>{t("updateTitle")}</strong>
<p style={hintStyle}>{t("updateHint")}</p>
<div style={{ display: "grid", gap: 4 }}>
<span>{t("currentVersion")}: {update.currentVersion ?? "-"}</span>
<span>{t("latestVersion")}: {update.latestVersion ?? "-"}</span>
</div>
{update.status === "available" ? <p role="status">{t("updateAvailable")}</p> : null}
{update.status === "current" ? <p role="status">{t("upToDate")}</p> : null}
{update.status === "updated" ? <p role="status">{t("updateComplete")}</p> : null}
{update.restartRequired ? <p role="status">{t("restartRequired")}</p> : null}
{update.error !== null ? <p role="status" style={{ color: "#b42318" }}>{update.error}</p> : null}
<div style={actionStyle}>
<button type="button" style={buttonStyle} disabled={update.checking || update.updating} onClick={props.checkForUpdate}>{t(update.checking ? "checkingForUpdates" : "checkForUpdates")}</button>
<button type="button" style={buttonStyle} disabled={update.checking || update.updating || update.status !== "available"} onClick={props.updatePackage}>{t(update.updating ? "updatingPackage" : "updateNow")}</button>
</div>
</div>
) : null}
<div style={actionStyle}>
{state.failed ? <span role="status" style={{ color: "#b42318", marginRight: "auto" }}>{t("saveFailed")}</span> : null}
{state.dirty ? <span style={{ marginRight: "auto", opacity: 0.68 }}>{t("unsaved")}</span> : null}
Expand Down
84 changes: 84 additions & 0 deletions packages/mtmharness/src/features/coding/client/controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import type { MtmUpdateResponse } from "../../update/contract.ts";
import { MtmCodingCardController } from "./controller.ts";

function settingsScope() {
let snapshot = {
status: "ready",
value: { codebaseMemoryEnabled: true, dynamicCanvasEnabled: false, codebaseMemoryAugmentHooks: true, modernGoEnabled: true, modernGoCommand: "", ponytailEnabled: true, ponytailMode: "full", ponytailSubagents: true, rtkMode: "auto", rtkAutoInstall: true, rtkCommand: "" },
base: {},
user: {},
revision: 1,
writable: true,
mode: "host",
};
return {
scope: {
getSnapshot: () => snapshot,
subscribe: () => () => {},
async set(field: string, value: unknown) { snapshot = { ...snapshot, value: { ...snapshot.value, [field]: value }, user: { ...snapshot.user, [field]: value } }; },
async unset(field: string) { const user = { ...snapshot.user }; delete user[field]; snapshot = { ...snapshot, user }; },
},
};
}

const response = (status: MtmUpdateResponse["status"], restartRequired = false): MtmUpdateResponse => ({
currentVersion: "0.5.5",
latestVersion: "0.6.0",
status,
error: null,
restartRequired,
});

async function flush(): Promise<void> {
await new Promise<void>((resolve) => { queueMicrotask(resolve); });
await new Promise<void>((resolve) => { queueMicrotask(resolve); });
}

describe("MtmCodingCardController update actions", () => {
it("hides update actions without a loopback RPC", () => {
const { scope } = settingsScope();
const controller = new MtmCodingCardController(scope as never);
expect(controller.inject().hooks.mtmCodingCard.getSnapshot().update).toMatchObject({ available: false, status: "idle" });
controller.dispose();
});

it("publishes checking, available, and restart-required states", async () => {
const { scope } = settingsScope();
let resolveRpc: ((result: unknown) => void) | undefined;
const calls: unknown[] = [];
const rpc = { call: async (_channel: string, _endpoint: string, payload: unknown) => {
calls.push(payload);
return await new Promise<unknown>((resolve) => { resolveRpc = resolve; });
} };
const controller = new MtmCodingCardController(scope as never, rpc as never);
const face = controller.inject();
face.checkForUpdate();
expect(face.hooks.mtmCodingCard.getSnapshot().update).toMatchObject({ available: true, checking: true, updating: false });
resolveRpc!({ ok: true, value: response("available") });
await flush();
expect(face.hooks.mtmCodingCard.getSnapshot().update).toMatchObject({ status: "available", checking: false, error: null });
face.updatePackage();
expect(face.hooks.mtmCodingCard.getSnapshot().update.updating).toBe(true);
resolveRpc!({ ok: true, value: response("updated", true) });
await flush();
expect(face.hooks.mtmCodingCard.getSnapshot().update).toMatchObject({ status: "updated", updating: false, restartRequired: true });
expect(calls).toEqual([
{ args: { kind: "check" } },
{ args: { kind: "update" } },
]);
controller.dispose();
});

it("keeps the card alive and reports malformed Host responses as errors", async () => {
const { scope } = settingsScope();
const rpc = { call: async () => ({ ok: true, value: { status: "available" } }) };
const controller = new MtmCodingCardController(scope as never, rpc as never);
const face = controller.inject();
face.checkForUpdate();
await flush();
expect(face.hooks.mtmCodingCard.getSnapshot().update).toMatchObject({ available: true, status: "failed", checking: false });
expect(face.hooks.mtmCodingCard.getSnapshot().update.error).toContain("currentVersion");
controller.dispose();
});
});
61 changes: 60 additions & 1 deletion packages/mtmharness/src/features/coding/client/controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ClientConnectionRpc } from "@deepseek-ai/dsh-client-connection/client";
import type { SettingsScope, SnapshotStore } from "@deepseek-ai/dsh-client-runtime/client";
import { createSnapshotStore } from "@deepseek-ai/dsh-client-runtime/client";
import { assertMtmUpdateResponse, MTM_UPDATE_CHANNEL, type MtmUpdateStatus } from "../../update/contract.js";
import type { MtmCodingSettings, PonytailMode, RtkMode } from "../types.js";

export const SETTINGS_NAMESPACE = "mtm-coding";
Expand All @@ -26,6 +28,17 @@ export interface FieldState {
readonly invalid: boolean;
}

export interface MtmUpdateCardState {
readonly available: boolean;
readonly checking: boolean;
readonly updating: boolean;
readonly currentVersion: string | null;
readonly latestVersion: string | null;
readonly status: MtmUpdateStatus | "idle";
readonly error: string | null;
readonly restartRequired: boolean;
}

export interface MtmCodingCardState {
readonly available: boolean;
readonly writable: boolean;
Expand All @@ -34,6 +47,7 @@ export interface MtmCodingCardState {
readonly saving: boolean;
readonly failed: boolean;
readonly fields: Readonly<Record<MtmCodingField, FieldState>>;
readonly update: MtmUpdateCardState;
}

export interface MtmCodingCardFace {
Expand All @@ -42,6 +56,8 @@ export interface MtmCodingCardFace {
readonly resetField: (field: MtmCodingField) => void;
readonly save: () => void;
readonly discard: () => void;
readonly checkForUpdate: () => void;
readonly updatePackage: () => void;
}

type StagedEdit = { readonly text: string; readonly clear: boolean };
Expand Down Expand Up @@ -77,12 +93,25 @@ function userHas(snapshot: ReturnType<SettingsScope<MtmCodingSettings>["getSnaps
export class MtmCodingCardController {
private readonly staged = new Map<MtmCodingField, StagedEdit>();
private readonly store: SnapshotStore<MtmCodingCardState>;
private readonly updateRpc: ClientConnectionRpc | undefined;
private updateState: MtmUpdateCardState;
private saving = false;
private failed = false;
private disposed = false;
private readonly unsubscribe: () => void;

constructor(private readonly scope: SettingsScope<MtmCodingSettings>) {
constructor(private readonly scope: SettingsScope<MtmCodingSettings>, updateRpc?: ClientConnectionRpc) {
this.updateRpc = updateRpc;
this.updateState = {
available: updateRpc !== undefined,
checking: false,
updating: false,
currentVersion: null,
latestVersion: null,
status: "idle",
error: null,
restartRequired: false,
};
this.store = createSnapshotStore(this.projection());
this.unsubscribe = scope.subscribe(() => { this.publish(); });
}
Expand All @@ -94,6 +123,8 @@ export class MtmCodingCardController {
resetField: (field) => { this.resetField(field); },
save: () => { void this.save(); },
discard: () => { this.discard(); },
checkForUpdate: () => { void this.requestUpdate("check"); },
updatePackage: () => { void this.requestUpdate("update"); },
};
}

Expand All @@ -102,6 +133,33 @@ export class MtmCodingCardController {
this.unsubscribe();
}

private async requestUpdate(kind: "check" | "update"): Promise<void> {
const rpc = this.updateRpc;
if (rpc === undefined || this.updateState.checking || this.updateState.updating) return;
this.updateState = {
...this.updateState,
checking: kind === "check",
updating: kind === "update",
error: null,
};
this.publish();
try {
const result = await rpc.call(MTM_UPDATE_CHANNEL, "request", { args: { kind } });
if (!result.ok) throw new Error(result.error.message);
assertMtmUpdateResponse(result.value);
this.updateState = { ...result.value, available: true, checking: false, updating: false };
} catch (error) {
this.updateState = {
...this.updateState,
checking: false,
updating: false,
status: "failed",
error: error instanceof Error ? error.message : String(error),
};
}
this.publish();
}

private edit(field: MtmCodingField, text: string): void {
this.staged.set(field, { text, clear: false });
this.failed = false;
Expand Down Expand Up @@ -199,6 +257,7 @@ export class MtmCodingCardController {
saving: this.saving,
failed: this.failed,
fields,
update: this.updateState,
};
}

Expand Down
7 changes: 5 additions & 2 deletions packages/mtmharness/src/features/coding/client/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client";
import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client";
import type {} from "@deepseek-ai/dsh-client-locale/client";
import type {} from "@deepseek-ai/dsh-client-ui-settings/client";
Expand All @@ -15,12 +16,14 @@ declare module "@deepseek-ai/dsh-client-ui-slots" {
}

export const name = "mtm-coding-client";
export const inject = ["slots", "locale", "settingsScope"];
export const inject = ["slots", "locale", "settingsScope", "connection"];

export function apply(ctx: ClientContext): void {
const t = ctx.locale.bind("mtm.coding");
ctx.effect(() => ctx.locale.register("mtm.coding", { en, zh }), "mtm-coding: locale");
const controller = new MtmCodingCardController(ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE }));
const connection = typeof ctx.get === "function" ? ctx.get("connection") as ConnectionHandle | undefined : undefined;
const updateRpc = connection?.isLoopback === true ? connection.rpc : undefined;
const controller = new MtmCodingCardController(ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE }), updateRpc);
ctx.effect(() => () => { controller.dispose(); }, "mtm-coding: settings card");
ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
name: "settings.plugin.item",
Expand Down
38 changes: 37 additions & 1 deletion packages/mtmharness/src/features/coding/client/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,19 @@ export type MtmCodingLocaleKey =
| "saveFailed"
| "readOnly"
| "show"
| "hide";
| "hide"
| "updateTitle"
| "updateHint"
| "currentVersion"
| "latestVersion"
| "checkForUpdates"
| "checkingForUpdates"
| "updateNow"
| "updatingPackage"
| "updateAvailable"
| "upToDate"
| "updateComplete"
| "restartRequired";

export const en: Record<MtmCodingLocaleKey, string> = {
nav: "Coding",
Expand Down Expand Up @@ -87,6 +99,18 @@ export const en: Record<MtmCodingLocaleKey, string> = {
readOnly: "This deployment stores settings read-only.",
show: "Show settings",
hide: "Hide settings",
updateTitle: "mtmharness update",
updateHint: "Check the stable npm release installed in this DSH Web profile. Updating requires a host restart.",
currentVersion: "Current version",
latestVersion: "Latest version",
checkForUpdates: "Check for updates",
checkingForUpdates: "Checking...",
updateNow: "Update now",
updatingPackage: "Updating...",
updateAvailable: "A newer stable version is available.",
upToDate: "This profile is up to date.",
updateComplete: "Update installed.",
restartRequired: "Restart DSH Web to load the update.",
};

export const zh: Record<MtmCodingLocaleKey, string> = {
Expand Down Expand Up @@ -133,4 +157,16 @@ export const zh: Record<MtmCodingLocaleKey, string> = {
readOnly: "本部署的设置为只读。",
show: "展开设置",
hide: "收起设置",
updateTitle: "mtmharness 更新",
updateHint: "检查此 DSH Web profile 中安装的稳定版 npm 包;更新后需要重启 Host。",
currentVersion: "当前版本",
latestVersion: "最新版本",
checkForUpdates: "检查更新",
checkingForUpdates: "检查中...",
updateNow: "立即更新",
updatingPackage: "更新中...",
updateAvailable: "有新的稳定版可用。",
upToDate: "此 profile 已是最新版本。",
updateComplete: "更新已安装。",
restartRequired: "请重启 DSH Web 以加载更新。",
};
Loading
Loading