fix(dashboard): write ChatView visualViewport vars imperatively
The mobile composer/footer slid over the message list when the user swiped with the keyboard up. Cause: --vv-height / --vv-offset-top were routed through React state via useMobileKeyboard, so on iOS — which fires visualViewport scroll/resize on the same frame as its keyboard animation — the .chat-thread translation lagged by one paint, visible as the composer momentarily floating over messages. Now those two vars are written imperatively in a useLayoutEffect directly to the .chat-thread DOM node on every visualViewport event, mirroring the working pattern at QuickChatFAB.tsx:1032-1052 (which already works correctly on mobile). Only --keyboard-overlap (a structural open/close signal, not per-frame) still flows through React state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getFusionSkillSourceCandidates,
|
||||
installFusionSkillIntoHermesHome,
|
||||
resolveBundledFusionSkillSourceFromCandidates,
|
||||
} from "../fusion-skill-install.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function makeSkillSource(dir: string): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path.join(dir, "SKILL.md"), "# Fusion Skill\n");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.HERMES_HOME;
|
||||
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("fusion skill installer", () => {
|
||||
it("resolves workspace and packaged candidate layouts", () => {
|
||||
const workspaceCandidates = getFusionSkillSourceCandidates("file:///repo/plugins/fusion-plugin-hermes-runtime/src/index.ts");
|
||||
expect(workspaceCandidates[0].replace(/\\/g, "/")).toMatch(/\/packages\/cli\/skill\/fusion$/);
|
||||
|
||||
const packagedCandidates = getFusionSkillSourceCandidates("file:///repo/packages/cli/dist/plugins/fusion-plugin-hermes-runtime/bundled.js");
|
||||
expect(
|
||||
packagedCandidates.some((candidate) => candidate.replace(/\\/g, "/").endsWith("/packages/cli/dist/skill/fusion")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("installs skill for configured Hermes home", () => {
|
||||
const home = tempDir("hermes-home-");
|
||||
const source = path.join(tempDir("fusion-source-"), "fusion");
|
||||
makeSkillSource(source);
|
||||
process.env.HERMES_HOME = home;
|
||||
|
||||
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
|
||||
expect(result.outcome).toBe("installed");
|
||||
expect(result.targetDir).toBe(path.join(home, "skills", "fusion"));
|
||||
});
|
||||
|
||||
it("no-ops when already installed to same source", () => {
|
||||
const home = tempDir("hermes-home-");
|
||||
const source = path.join(tempDir("fusion-source-"), "fusion");
|
||||
makeSkillSource(source);
|
||||
process.env.HERMES_HOME = home;
|
||||
|
||||
expect(installFusionSkillIntoHermesHome({ sourceDir: source }).outcome).toBe("installed");
|
||||
expect(installFusionSkillIntoHermesHome({ sourceDir: source }).outcome).toBe("already-installed");
|
||||
});
|
||||
|
||||
it("replaces prior fusion directory install safely", () => {
|
||||
const home = tempDir("hermes-home-");
|
||||
const source = path.join(tempDir("fusion-source-"), "fusion");
|
||||
makeSkillSource(source);
|
||||
process.env.HERMES_HOME = home;
|
||||
|
||||
const target = path.join(home, "skills", "fusion");
|
||||
makeSkillSource(target);
|
||||
writeFileSync(path.join(target, "old.txt"), "stale");
|
||||
|
||||
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
|
||||
expect(result.outcome).toBe("replaced");
|
||||
});
|
||||
|
||||
it("skips replacement for unrelated existing directory", () => {
|
||||
const home = tempDir("hermes-home-");
|
||||
const source = path.join(tempDir("fusion-source-"), "fusion");
|
||||
makeSkillSource(source);
|
||||
process.env.HERMES_HOME = home;
|
||||
|
||||
const target = path.join(home, "skills", "fusion");
|
||||
mkdirSync(target, { recursive: true });
|
||||
writeFileSync(path.join(target, "README.md"), "my custom skill");
|
||||
|
||||
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
|
||||
expect(result.outcome).toBe("skipped");
|
||||
});
|
||||
|
||||
it("warns when source is missing", () => {
|
||||
const home = tempDir("hermes-home-");
|
||||
process.env.HERMES_HOME = home;
|
||||
|
||||
const result = installFusionSkillIntoHermesHome({ sourceDir: null });
|
||||
expect(result.outcome).toBe("warning");
|
||||
});
|
||||
|
||||
it("can resolve first existing candidate", () => {
|
||||
const source = path.join(tempDir("fusion-source-"), "fusion");
|
||||
makeSkillSource(source);
|
||||
const resolved = resolveBundledFusionSkillSourceFromCandidates(["/missing", source]);
|
||||
expect(resolved).toBe(source);
|
||||
});
|
||||
|
||||
it("replaces stale fusion symlink", () => {
|
||||
const home = tempDir("hermes-home-");
|
||||
const source = path.join(tempDir("fusion-source-"), "fusion");
|
||||
const oldSource = path.join(tempDir("fusion-old-source-"), "fusion");
|
||||
makeSkillSource(source);
|
||||
makeSkillSource(oldSource);
|
||||
process.env.HERMES_HOME = home;
|
||||
|
||||
const target = path.join(home, "skills", "fusion");
|
||||
mkdirSync(path.dirname(target), { recursive: true });
|
||||
symlinkSync(oldSource, target, "dir");
|
||||
|
||||
const result = installFusionSkillIntoHermesHome({ sourceDir: source });
|
||||
expect(result.outcome).toBe("replaced");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockResolveCli } = vi.hoisted(() => ({
|
||||
const { mockResolveCli, mockInstallFusionSkill } = vi.hoisted(() => ({
|
||||
mockResolveCli: vi.fn().mockReturnValue({
|
||||
binaryPath: "hermes",
|
||||
model: undefined,
|
||||
@@ -9,23 +9,32 @@ const { mockResolveCli } = vi.hoisted(() => ({
|
||||
yolo: false,
|
||||
cliTimeoutMs: 300_000,
|
||||
}),
|
||||
mockInstallFusionSkill: vi.fn().mockReturnValue({
|
||||
outcome: "installed",
|
||||
sourceDir: "/tmp/source",
|
||||
targetDir: "/tmp/target",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../cli-spawn.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../cli-spawn.js")>(
|
||||
"../cli-spawn.js",
|
||||
);
|
||||
const actual = await vi.importActual<typeof import("../cli-spawn.js")>("../cli-spawn.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveCliSettings: mockResolveCli,
|
||||
};
|
||||
});
|
||||
|
||||
import plugin, {
|
||||
hermesRuntimeMetadata,
|
||||
hermesRuntimeFactory,
|
||||
HERMES_RUNTIME_ID,
|
||||
} from "../index.js";
|
||||
vi.mock("../fusion-skill-install.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../fusion-skill-install.js")>(
|
||||
"../fusion-skill-install.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
installFusionSkillIntoHermesHome: mockInstallFusionSkill,
|
||||
};
|
||||
});
|
||||
|
||||
import plugin, { HERMES_RUNTIME_ID, hermesRuntimeFactory, hermesRuntimeMetadata } from "../index.js";
|
||||
import { HermesRuntimeAdapter } from "../runtime-adapter.js";
|
||||
|
||||
function createMockContext(settings: Record<string, unknown> = {}) {
|
||||
@@ -48,6 +57,12 @@ describe("hermes-runtime plugin", () => {
|
||||
maxTurns: 12,
|
||||
yolo: false,
|
||||
cliTimeoutMs: 300_000,
|
||||
profile: undefined,
|
||||
});
|
||||
mockInstallFusionSkill.mockReturnValue({
|
||||
outcome: "installed",
|
||||
sourceDir: "/tmp/source",
|
||||
targetDir: "/tmp/target",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,13 +88,33 @@ describe("hermes-runtime plugin", () => {
|
||||
maxTurns: 12,
|
||||
yolo: false,
|
||||
cliTimeoutMs: 300_000,
|
||||
profile: undefined,
|
||||
});
|
||||
|
||||
const ctx = createMockContext({ binaryPath: "/opt/homebrew/bin/hermes" });
|
||||
await plugin.hooks!.onLoad!(ctx as any);
|
||||
|
||||
expect(mockResolveCli).toHaveBeenCalledWith(ctx.settings);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/opt/homebrew/bin/hermes"),
|
||||
);
|
||||
expect(mockInstallFusionSkill).toHaveBeenCalledWith({ profile: undefined });
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(expect.stringContaining("fusionSkill=installed"));
|
||||
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
|
||||
runtimeId: "hermes",
|
||||
version: plugin.manifest.version,
|
||||
});
|
||||
});
|
||||
|
||||
it("onLoad warns but continues when skill install warns", async () => {
|
||||
mockInstallFusionSkill.mockReturnValue({
|
||||
outcome: "warning",
|
||||
sourceDir: null,
|
||||
targetDir: "/tmp/target",
|
||||
reason: "missing skill source",
|
||||
});
|
||||
|
||||
const ctx = createMockContext();
|
||||
await plugin.hooks!.onLoad!(ctx as any);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(expect.stringContaining("auto-install warning"));
|
||||
expect(ctx.emitEvent).toHaveBeenCalledWith("hermes-runtime:loaded", {
|
||||
runtimeId: "hermes",
|
||||
version: plugin.manifest.version,
|
||||
|
||||
Reference in New Issue
Block a user