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:
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type HostAppearanceSource,
|
||||
defaultHostAppearance,
|
||||
normalizeStoredHostAppearance,
|
||||
resolveHostBadgeDisplay,
|
||||
selectHostBadges,
|
||||
} from "@/hosts/appearance";
|
||||
|
||||
function host(
|
||||
serverId: string,
|
||||
label: string,
|
||||
appearance = defaultHostAppearance(),
|
||||
): HostAppearanceSource {
|
||||
return { serverId, label, appearance };
|
||||
}
|
||||
|
||||
describe("normalizeStoredHostAppearance", () => {
|
||||
it("defaults when the stored registry predates the field or holds junk", () => {
|
||||
const expected = { color: "none", badgeDisplay: null };
|
||||
expect(normalizeStoredHostAppearance(undefined)).toEqual(expected);
|
||||
expect(normalizeStoredHostAppearance(null)).toEqual(expected);
|
||||
expect(normalizeStoredHostAppearance("x")).toEqual(expected);
|
||||
expect(normalizeStoredHostAppearance({})).toEqual(expected);
|
||||
expect(normalizeStoredHostAppearance({ color: "chartreuse" })).toEqual(expected);
|
||||
expect(normalizeStoredHostAppearance({ badgeDisplay: "loud" })).toEqual(expected);
|
||||
});
|
||||
|
||||
it("round-trips a value the user actually chose", () => {
|
||||
expect(normalizeStoredHostAppearance({ color: "teal", badgeDisplay: "icon" })).toEqual({
|
||||
color: "teal",
|
||||
badgeDisplay: "icon",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHostBadgeDisplay", () => {
|
||||
it("hides the badge for the local host until the user chooses", () => {
|
||||
expect(
|
||||
resolveHostBadgeDisplay({ appearance: defaultHostAppearance(), isLocalHost: true }),
|
||||
).toBe("hidden");
|
||||
});
|
||||
|
||||
it("names a remote host until the user chooses", () => {
|
||||
expect(
|
||||
resolveHostBadgeDisplay({ appearance: defaultHostAppearance(), isLocalHost: false }),
|
||||
).toBe("name");
|
||||
});
|
||||
|
||||
it("prefers an explicit choice over either default", () => {
|
||||
expect(
|
||||
resolveHostBadgeDisplay({
|
||||
appearance: { color: "none", badgeDisplay: "icon" },
|
||||
isLocalHost: true,
|
||||
}),
|
||||
).toBe("icon");
|
||||
expect(
|
||||
resolveHostBadgeDisplay({
|
||||
appearance: { color: "none", badgeDisplay: "hidden" },
|
||||
isLocalHost: false,
|
||||
}),
|
||||
).toBe("hidden");
|
||||
});
|
||||
|
||||
it("defers only the default while local-host detection is unresolved", () => {
|
||||
expect(
|
||||
resolveHostBadgeDisplay({
|
||||
appearance: defaultHostAppearance(),
|
||||
isLocalHost: false,
|
||||
localHostResolutionPending: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveHostBadgeDisplay({
|
||||
appearance: { color: "none", badgeDisplay: "icon" },
|
||||
isLocalHost: false,
|
||||
localHostResolutionPending: true,
|
||||
}),
|
||||
).toBe("icon");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectHostBadges", () => {
|
||||
it("shows no badges while the sidebar spans a single host", () => {
|
||||
const badges = selectHostBadges({
|
||||
hosts: [host("alpha", "Alpha"), host("beta", "Beta")],
|
||||
localServerId: null,
|
||||
enabled: false,
|
||||
});
|
||||
expect(badges.size).toBe(0);
|
||||
});
|
||||
|
||||
it("omits a host the user hid and keeps its sibling", () => {
|
||||
const badges = selectHostBadges({
|
||||
hosts: [
|
||||
host("alpha", "Alpha", { color: "none", badgeDisplay: "hidden" }),
|
||||
host("beta", "Beta"),
|
||||
],
|
||||
localServerId: null,
|
||||
enabled: true,
|
||||
});
|
||||
expect(badges.has("alpha")).toBe(false);
|
||||
expect(badges.get("beta")).toEqual({
|
||||
serverId: "beta",
|
||||
label: "Beta",
|
||||
color: "none",
|
||||
showLabel: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an icon-only host in the map without its label", () => {
|
||||
const badges = selectHostBadges({
|
||||
hosts: [host("alpha", "Alpha", { color: "teal", badgeDisplay: "icon" })],
|
||||
localServerId: null,
|
||||
enabled: true,
|
||||
});
|
||||
expect(badges.get("alpha")).toEqual({
|
||||
serverId: "alpha",
|
||||
label: "Alpha",
|
||||
color: "teal",
|
||||
showLabel: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the server id when the label is blank", () => {
|
||||
const badges = selectHostBadges({
|
||||
hosts: [host("alpha", " ")],
|
||||
localServerId: null,
|
||||
enabled: true,
|
||||
});
|
||||
expect(badges.get("alpha")?.label).toBe("alpha");
|
||||
});
|
||||
|
||||
it("hides an untouched local host while its remote sibling shows", () => {
|
||||
const badges = selectHostBadges({
|
||||
hosts: [host("alpha", "Alpha"), host("beta", "Beta")],
|
||||
localServerId: "alpha",
|
||||
enabled: true,
|
||||
});
|
||||
expect(badges.has("alpha")).toBe(false);
|
||||
expect(badges.get("beta")?.label).toBe("Beta");
|
||||
});
|
||||
|
||||
it("omits untouched badges while local-host detection is unresolved", () => {
|
||||
const badges = selectHostBadges({
|
||||
hosts: [host("alpha", "Alpha"), host("beta", "Beta")],
|
||||
localServerId: null,
|
||||
localHostResolutionPending: true,
|
||||
enabled: true,
|
||||
});
|
||||
expect(badges.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
|
||||
export type HostColor = "none" | IdentityColorName;
|
||||
|
||||
export const HOST_COLORS: readonly HostColor[] = ["none", ...IDENTITY_COLOR_NAMES];
|
||||
|
||||
export type HostBadgeDisplay = "name" | "icon" | "hidden";
|
||||
|
||||
export const HOST_BADGE_DISPLAYS: readonly HostBadgeDisplay[] = ["name", "icon", "hidden"];
|
||||
|
||||
/**
|
||||
* Per-device host presentation. `badgeDisplay` is null while the user has not chosen,
|
||||
* because the default differs by host (local hides, remote shows) and local-ness is only
|
||||
* knowable from a desktop-only async query — never at parse time.
|
||||
*/
|
||||
export interface HostAppearance {
|
||||
color: HostColor;
|
||||
badgeDisplay: HostBadgeDisplay | null;
|
||||
}
|
||||
|
||||
export function defaultHostAppearance(): HostAppearance {
|
||||
return { color: "none", badgeDisplay: null };
|
||||
}
|
||||
|
||||
function isHostColor(value: unknown): value is HostColor {
|
||||
return HOST_COLORS.some((color) => color === value);
|
||||
}
|
||||
|
||||
function isHostBadgeDisplay(value: unknown): value is HostBadgeDisplay {
|
||||
return HOST_BADGE_DISPLAYS.some((display) => display === value);
|
||||
}
|
||||
|
||||
export function normalizeStoredHostAppearance(value: unknown): HostAppearance {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return defaultHostAppearance();
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return {
|
||||
color: isHostColor(record.color) ? record.color : "none",
|
||||
badgeDisplay: isHostBadgeDisplay(record.badgeDisplay) ? record.badgeDisplay : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveHostBadgeDisplay(input: {
|
||||
appearance: HostAppearance;
|
||||
isLocalHost: boolean;
|
||||
localHostResolutionPending?: boolean;
|
||||
}): HostBadgeDisplay | null {
|
||||
if (input.appearance.badgeDisplay) {
|
||||
return input.appearance.badgeDisplay;
|
||||
}
|
||||
if (input.localHostResolutionPending) {
|
||||
return null;
|
||||
}
|
||||
return input.isLocalHost ? "hidden" : "name";
|
||||
}
|
||||
|
||||
export interface HostBadgeModel {
|
||||
serverId: string;
|
||||
label: string;
|
||||
color: HostColor;
|
||||
showLabel: boolean;
|
||||
}
|
||||
|
||||
export type HostAppearanceSource = Pick<HostProfile, "serverId" | "label" | "appearance">;
|
||||
|
||||
/**
|
||||
* The sidebar's whole host-badge decision, resolved once per host list. Rows look their
|
||||
* badge up by serverId and render whatever they find; a host that should show no badge is
|
||||
* simply absent from the map.
|
||||
*/
|
||||
export function selectHostBadges(input: {
|
||||
hosts: readonly HostAppearanceSource[];
|
||||
localServerId: string | null;
|
||||
localHostResolutionPending?: boolean;
|
||||
enabled: boolean;
|
||||
}): ReadonlyMap<string, HostBadgeModel> {
|
||||
const badges = new Map<string, HostBadgeModel>();
|
||||
if (!input.enabled) {
|
||||
return badges;
|
||||
}
|
||||
for (const host of input.hosts) {
|
||||
const display = resolveHostBadgeDisplay({
|
||||
appearance: host.appearance,
|
||||
isLocalHost: host.serverId === input.localServerId,
|
||||
localHostResolutionPending: input.localHostResolutionPending,
|
||||
});
|
||||
if (display === null || display === "hidden") {
|
||||
continue;
|
||||
}
|
||||
badges.set(host.serverId, {
|
||||
serverId: host.serverId,
|
||||
label: host.label.trim() || host.serverId,
|
||||
color: host.appearance.color,
|
||||
showLabel: display === "name",
|
||||
});
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
Reference in New Issue
Block a user