From 59865d665c5ddb117e3c247d44d290884dde7f43 Mon Sep 17 00:00:00 2001
From: Masaki Aida <51500566+aida0710@users.noreply.github.com>
Date: Wed, 11 Nov 2026 20:24:28 +0900
Subject: [PATCH] =?UTF-8?q?fix(web):=20=E5=9B=9E=E3=82=8B=E5=8D=B0?=
=?UTF-8?q?=E3=82=92=E3=80=81=E3=81=84=E3=81=A4=E4=BB=98=E3=81=84=E3=81=9F?=
=?UTF-8?q?=E3=82=82=E3=81=AE=E3=82=82=E5=90=8C=E3=81=98=E5=90=91=E3=81=8D?=
=?UTF-8?q?=E3=81=A7=E5=9B=9E=E3=81=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
回る印(animate-spin)は付いた瞬間から回り始めるので、2つのセッションで作業が始まった時刻が違うと、
片方は上、片方は下を向いて回っていた。印のアニメーションの始まりをページの時計の 0 に置き、
セッションの行の印と、待っている間の Spinner をどれも同じ向きで回す。
Claude-Session: https://claude.ai/code/session_014WPnWu8NLT7prLQhhzYpc1
---
web/e2e/terminal-osc.spec.ts | 33 ++++++++++++++++++++++++++++++
web/src/terminal/SessionMarker.tsx | 11 ++++++++--
web/src/ui/Spinner.tsx | 4 ++++
web/src/ui/useSharedTurn.ts | 13 ++++++++++++
4 files changed, 59 insertions(+), 2 deletions(-)
create mode 100644 web/src/ui/useSharedTurn.ts
diff --git a/web/e2e/terminal-osc.spec.ts b/web/e2e/terminal-osc.spec.ts
index ea853873c..6c6f38c6e 100644
--- a/web/e2e/terminal-osc.spec.ts
+++ b/web/e2e/terminal-osc.spec.ts
@@ -245,6 +245,39 @@ test("a session's row follows a program that works for less than the time betwee
await expect(sessions.locator("[data-program-status]")).toHaveCount(0);
});
+// Two programs that began working at different moments turn their rings together. Left to start when each appears,
+// one ring points up while the other points down.
+test("the rings of two working programs turn in step, whenever each began", async ({ page, installation }) => {
+ test.skip(process.platform === "win32", "printf escape sequences are POSIX shell syntax");
+ await openApplication(page, installation);
+ const sessions = page.getByRole("navigation", { name: "Primary" }).getByRole("list", { name: "Open sessions" });
+ const screen = page.getByRole("region", { name: /^Terminal for / });
+ // Both sessions are open before either program works, so a ring appears in a list that is already drawn.
+ await openLocalShell(page);
+ await openLocalShell(page);
+ await expect(sessions.getByRole("listitem")).toHaveCount(2);
+ const rings = sessions.locator('[data-program-status="working"]');
+ for (const row of [1, 0]) {
+ await sessions.getByRole("listitem").nth(row).getByRole("button").first().click();
+ await expect(screen).not.toContainText("read -r");
+ await expect(screen).toContainText(/[$#%>]/, { timeout: 20_000 });
+ await terminalKeyboard(page).focus();
+ await page.keyboard.type(`${reportProgramStatus("state=working")}; read -r _`);
+ await page.keyboard.press("Enter");
+ await expect(rings).toHaveCount(2 - row, { timeout: 15_000 });
+ // The second program begins a part of a turn after the first.
+ await page.waitForTimeout(370);
+ }
+
+ // Both angles are read in one step, so the rings have no time to turn between the two readings.
+ const degrees = await rings.evaluateAll((marks) => marks.map((mark) => {
+ const turn = new DOMMatrixReadOnly(getComputedStyle(mark).transform);
+ return (Math.atan2(turn.b, turn.a) * 180) / Math.PI;
+ }));
+ const apart = Math.abs(((degrees[0]! - degrees[1]! + 540) % 360) - 180);
+ expect(apart, `the rings stand at ${degrees.map((angle) => angle.toFixed(0)).join("° and ")}°`).toBeLessThan(2);
+});
+
// Programs say what they are doing with OSC 7501. The session's row shows it in
// place of its dot and destination: working and blocked while they last, a done
// result until the session has been on screen since it came.
diff --git a/web/src/terminal/SessionMarker.tsx b/web/src/terminal/SessionMarker.tsx
index aea9ef9fe..c06fbfb6a 100644
--- a/web/src/terminal/SessionMarker.tsx
+++ b/web/src/terminal/SessionMarker.tsx
@@ -2,6 +2,7 @@ import type { CSSProperties } from "react";
import type { TerminalSession } from "../api/terminalSessions";
import type { ProgramProgress } from "./programProgress";
import type { ShownProgramStatus } from "./programStatus";
+import { useSharedTurn } from "../ui/useSharedTurn";
import { sessionMarkerClass } from "./sessionStatus";
// The connection dot takes the size of the program status marks, an 11px circle,
@@ -54,8 +55,14 @@ export function SessionMarker({ session, unread, programStatus, programProgress
style={{ "--sshc-progress": `${programProgress.percent}%` } as CSSProperties} />;
}
if (programStatus !== null || programProgress !== null) {
- const source = programStatus === null ? { "data-program-progress": "indeterminate" } : { "data-program-status": "working" };
- return ;
+ return ;
}
return ;
}
+
+// TurningRing is the ring that turns while a program works without saying how far. It is a component of its own so
+// that it is set in step with the other rows' rings when it appears, whenever its program began.
+function TurningRing({ source }: { source: { "data-program-progress": "indeterminate" } | { "data-program-status": "working" } }) {
+ const ring = useSharedTurn();
+ return ;
+}
diff --git a/web/src/ui/Spinner.tsx b/web/src/ui/Spinner.tsx
index 28023fb89..a25de5340 100644
--- a/web/src/ui/Spinner.tsx
+++ b/web/src/ui/Spinner.tsx
@@ -1,9 +1,13 @@
+import { useSharedTurn } from "./useSharedTurn";
+
// Spinner は、終わるまでの時間が分からない処理を待っている間に回る輪である。色は文字の色を
// 受け継ぐので、置く場所の text-* で変える。読み上げには出さず、待っていることは近くの文か
// aria-busy で伝える。動きを減らす設定のときは index.css の決まりで止まる。
export function Spinner({ className = "size-3.5" }: { className?: string }) {
+ const ring = useSharedTurn();
return (
diff --git a/web/src/ui/useSharedTurn.ts b/web/src/ui/useSharedTurn.ts
new file mode 100644
index 000000000..e642828f3
--- /dev/null
+++ b/web/src/ui/useSharedTurn.ts
@@ -0,0 +1,13 @@
+import { useLayoutEffect, useRef } from "react";
+
+// 回る印(animate-spin)は、付いた瞬間から回り始める。そのままだと、同じ画面にある2つの印の向きが
+// そろわない(片方は上、片方は下を向いて回る)。useSharedTurn は、印のアニメーションの始まりをページの
+// 時計の 0 に置き、いつ付いた印も同じ向きで回るようにする。返す ref を回る要素に付ける。
+export function useSharedTurn() {
+ const mark = useRef(null);
+ useLayoutEffect(() => {
+ // 動きを減らす設定ではアニメーションが無く、何もしない。jsdom は getAnimations を持たない。
+ for (const animation of mark.current?.getAnimations?.() ?? []) animation.startTime = 0;
+ }, []);
+ return mark;
+}