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:
@@ -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: [
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user