Improve sidebar workspace readability and host identity (#2790)

* fix(app): align turn footer actions

Keep running-turn actions in the same position as completed-turn actions and use the shared small icon size for both copy and fork controls.

* feat(app): improve sidebar workspace identity

Make dense sidebar workspace rows easier to scan and let each device control how connected hosts are identified.

* fix(app): keep host badges readable

Use a compact display menu and keep identity color on the host icon so translated controls fit and badge text retains standard contrast.

* fix(app): announce selected sidebar badge mode

* fix(app): retain icon-only host names for screen readers

* fix(app): name hoisted workspace rows

* fix(app): retain host identity in workspace labels

* fix(app): retain workspace status in row labels

* fix(app): defer host appearance until identity resolves

* fix(app): serialize host appearance writes

* fix(app): serialize host registry writes

* fix(app): bound sidebar host badge labels

* test(app): match workspace status labels

* fix(app): consolidate sidebar identity behavior

* test(app): assert status grouping behavior

* fix(app): keep sidebar row actions accessible

* fix(app): stabilize optimistic message layout
This commit is contained in:
Mohamed Boudra
2026-08-02 16:58:20 +02:00
committed by GitHub
parent 553ac53643
commit ccb668b3b4
57 changed files with 3251 additions and 809 deletions
@@ -9,6 +9,7 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { defaultHostAppearance } from "@/hosts/appearance";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
@@ -348,6 +349,7 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
return {
serverId: input?.serverId ?? "srv_test",
label: input?.label ?? "test host",
appearance: input?.appearance ?? defaultHostAppearance(),
lifecycle: input?.lifecycle ?? {},
connections: input?.connections ?? [direct, relay],
preferredConnectionId: input?.preferredConnectionId ?? direct.id,
@@ -446,6 +448,21 @@ function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): H
};
}
function createAppearanceStore(storage: HostRuntimeStorage): HostRuntimeStore {
return new HostRuntimeStore({
storage,
deps: {
createClient: () => {
throw new Error("createClient should not be called");
},
connectToDaemon: async () => {
throw new Error("connectToDaemon should not be called");
},
getClientId: async () => "cid_test_appearance",
},
});
}
function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean): Promise<void> {
if (predicate()) {
return Promise.resolve();
@@ -1477,6 +1494,151 @@ describe("HostRuntimeStore", () => {
}
});
it("exposes the default appearance for a host stored before the field existed", async () => {
const storage = createMemoryHostRuntimeStorage();
await storage.setItem(
"@paseo:daemon-registry",
JSON.stringify([
{
serverId: "srv_legacy",
label: "Legacy",
connections: [
{ id: "socket:/tmp/legacy.sock", type: "directSocket", path: "/tmp/legacy.sock" },
],
preferredConnectionId: "socket:/tmp/legacy.sock",
},
]),
);
await storage.setItem("@paseo:e2e", "1");
const store = createAppearanceStore(storage);
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
store.boot();
await registryLoaded;
expect(store.getHosts()[0]?.appearance).toEqual({ color: "none", badgeDisplay: null });
store.syncHosts([]);
});
it("records a chosen host color and writes it through to storage", async () => {
const host = makeHost({ serverId: "srv_appearance", updatedAt: new Date(0).toISOString() });
const storage = createMemoryHostRuntimeStorage();
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
await storage.setItem("@paseo:e2e", "1");
const store = createAppearanceStore(storage);
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
store.boot();
await registryLoaded;
const hostListChanged = onceHostListMatches(
store,
() => store.getHosts()[0]?.appearance.color === "teal",
);
await store.setHostColor("srv_appearance", "teal");
await hostListChanged;
const updated = store.getHosts()[0];
expect(updated?.appearance).toEqual({ color: "teal", badgeDisplay: null });
expect(updated?.updatedAt).not.toBe(host.updatedAt);
const persisted = await storage.getItem("@paseo:daemon-registry");
expect(JSON.parse(persisted ?? "[]")[0].appearance).toEqual({
color: "teal",
badgeDisplay: null,
});
store.syncHosts([]);
});
it("records a chosen badge display without disturbing the color", async () => {
const host = makeHost({
serverId: "srv_appearance",
appearance: { color: "amber", badgeDisplay: null },
});
const storage = createMemoryHostRuntimeStorage();
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
await storage.setItem("@paseo:e2e", "1");
const store = createAppearanceStore(storage);
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
store.boot();
await registryLoaded;
const hostListChanged = onceHostListMatches(
store,
() => store.getHosts()[0]?.appearance.badgeDisplay === "icon",
);
await store.setHostBadgeDisplay("srv_appearance", "icon");
await hostListChanged;
expect(store.getHosts()[0]?.appearance).toEqual({ color: "amber", badgeDisplay: "icon" });
const persisted = await storage.getItem("@paseo:daemon-registry");
expect(JSON.parse(persisted ?? "[]")[0].appearance).toEqual({
color: "amber",
badgeDisplay: "icon",
});
store.syncHosts([]);
});
it("keeps host appearance unchanged when persistence fails", async () => {
const host = makeHost({ serverId: "srv_appearance" });
const storage = createMemoryHostRuntimeStorage();
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
await storage.setItem("@paseo:e2e", "1");
const store = createAppearanceStore(storage);
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
store.boot();
await registryLoaded;
storage.setItem = async () => {
throw new Error("disk full");
};
await expect(store.setHostColor("srv_appearance", "teal")).rejects.toThrow("disk full");
expect(store.getHosts()[0]?.appearance).toEqual(defaultHostAppearance());
store.syncHosts([]);
});
it("serializes overlapping host appearance writes", async () => {
const host = makeHost({ serverId: "srv_appearance" });
const storage = createMemoryHostRuntimeStorage();
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
await storage.setItem("@paseo:e2e", "1");
const store = createAppearanceStore(storage);
const registryLoaded = onceHostListMatches(store, () => store.isHostRegistryLoaded());
store.boot();
await registryLoaded;
const firstWrite = createDeferred<void>();
let writeCount = 0;
const setItem = storage.setItem.bind(storage);
storage.setItem = async (key, value) => {
writeCount += 1;
if (writeCount === 1) await firstWrite.promise;
await setItem(key, value);
};
const color = store.setHostColor("srv_appearance", "teal");
const display = store.setHostBadgeDisplay("srv_appearance", "icon");
await Promise.resolve();
expect(writeCount).toBe(1);
firstWrite.resolve();
await Promise.all([color, display]);
expect(store.getHosts()[0]?.appearance).toEqual({ color: "teal", badgeDisplay: "icon" });
const persistedHosts = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
expect(persistedHosts[0]?.appearance).toEqual({ color: "teal", badgeDisplay: "icon" });
store.syncHosts([]);
});
it("bootstraps agent directory subscription when host transitions online", async () => {
const host = makeHost({
connections: [
+67 -12
View File
@@ -14,6 +14,7 @@ import {
type HostConnection,
type HostProfile,
} from "@/types/host-connection";
import { defaultHostAppearance, type HostBadgeDisplay, type HostColor } from "@/hosts/appearance";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl,
@@ -1347,6 +1348,7 @@ export class HostRuntimeStore {
private hostListVersion = 0;
private hostRegistryLoaded = false;
private hosts: HostProfile[] = [];
private hostAppearanceMutationTail: Promise<void> = Promise.resolve();
private hostRegistryStatus: HostRegistryStatus = "loading";
private deps: HostRuntimeControllerDeps;
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
@@ -1461,7 +1463,9 @@ export class HostRuntimeStore {
this.hostRegistryStatus = "ready";
this.emitHostList();
if (shouldPersistHosts) {
void this.persistHosts();
void this.persistHosts().catch((error) =>
console.error("[HostRuntime] Failed to persist host registry", error),
);
}
}
}
@@ -1626,7 +1630,9 @@ export class HostRuntimeStore {
);
this.emitHostList();
this.emit(newServerId);
void this.persistHosts();
void this.persistHosts().catch((error) =>
console.error("[HostRuntime] Failed to persist host registry", error),
);
}
async upsertDirectConnection(input: {
@@ -1664,6 +1670,7 @@ export class HostRuntimeStore {
const probeHost: HostProfile = {
serverId: "",
label: input.label ?? input.connection.id,
appearance: defaultHostAppearance(),
lifecycle: {},
connections: [input.connection],
preferredConnectionId: input.connection.id,
@@ -1782,14 +1789,59 @@ export class HostRuntimeStore {
});
}
async renameHost(serverId: string, label: string): Promise<void> {
const next = this.hosts.map((h) =>
h.serverId === serverId ? { ...h, label, updatedAt: new Date().toISOString() } : h,
private async updateHost(
serverId: string,
apply: (host: HostProfile) => HostProfile,
): Promise<void> {
const updatedAt = new Date().toISOString();
const next = this.hosts.map((host) =>
host.serverId === serverId ? { ...apply(host), updatedAt } : host,
);
this.setHostsAndSync(next);
await this.persistHosts();
}
async renameHost(serverId: string, label: string): Promise<void> {
await this.updateHost(serverId, (host) => ({ ...host, label }));
}
async setHostColor(serverId: string, color: HostColor): Promise<void> {
await this.updateHostAppearance(serverId, (host) => ({
...host,
appearance: { ...host.appearance, color },
}));
}
async setHostBadgeDisplay(serverId: string, badgeDisplay: HostBadgeDisplay): Promise<void> {
await this.updateHostAppearance(serverId, (host) => ({
...host,
appearance: { ...host.appearance, badgeDisplay },
}));
}
private updateHostAppearance(
serverId: string,
apply: (host: HostProfile) => HostProfile,
): Promise<void> {
const update = this.hostAppearanceMutationTail.then(() =>
this.applyHostAppearance(serverId, apply),
);
this.hostAppearanceMutationTail = update.catch(() => undefined);
return update;
}
private async applyHostAppearance(
serverId: string,
apply: (host: HostProfile) => HostProfile,
): Promise<void> {
const updatedAt = new Date().toISOString();
const next = this.hosts.map((host) =>
host.serverId === serverId ? { ...apply(host), updatedAt } : host,
);
await this.persistHosts(next);
this.setHostsAndSync(next);
}
async removeHost(serverId: string): Promise<void> {
const remaining = this.hosts.filter((daemon) => daemon.serverId !== serverId);
this.setHostsAndSync(remaining);
@@ -1848,7 +1900,9 @@ export class HostRuntimeStore {
])
: undefined,
});
void this.persistHosts();
void this.persistHosts().catch((error) =>
console.error("[HostRuntime] Failed to persist host registry", error),
);
return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile;
}
@@ -1866,12 +1920,8 @@ export class HostRuntimeStore {
this.emitHostList();
}
private async persistHosts(): Promise<void> {
try {
await this.storage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(this.hosts));
} catch (error) {
console.error("[HostRuntime] Failed to persist host registry", error);
}
private async persistHosts(hosts = this.hosts): Promise<void> {
await this.storage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(hosts));
}
private emitHostList(): void {
@@ -2421,6 +2471,8 @@ export interface HostMutations {
label?: string,
) => Promise<HostProfile>;
renameHost: (serverId: string, label: string) => Promise<void>;
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
removeHost: (serverId: string) => Promise<void>;
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
}
@@ -2435,6 +2487,9 @@ export function useHostMutations(): HostMutations {
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
renameHost: (serverId, label) => store.renameHost(serverId, label),
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
setHostBadgeDisplay: (serverId, badgeDisplay) =>
store.setHostBadgeDisplay(serverId, badgeDisplay),
removeHost: (serverId) => store.removeHost(serverId),
removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),
}),