Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ reimplementing it in this repo.
The shell has exactly one nav surface: the sidebar. There is no second nav
column and no collapse affordance — it is always present, at a fixed width.

Conversation rows keep a fixed avatar footprint and two compact text lines:
the name with its timestamp aligned on the right, then the latest message
preview. Selection uses a quiet neutral fill; the name stays in the normal
foreground color. While an agent works, a thin orange orbit surrounds the
avatar, including when that conversation is selected. A completed, unread
agent reply replaces the orbit with a small green check labeled "Reply
ready". Opening the conversation clears that check; idle, read, failed,
and cancelled turns show no completion badge. Reduced motion keeps the
working indicator as a static orange arc.

Top to bottom:

1. **Brand row** — logo mark and a "New workbench" button (`+`) that
Expand Down
166 changes: 82 additions & 84 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1464,100 +1464,100 @@ select:disabled,
color: var(--shell-panel-faint);
}

/* Mock .ch rows in the sidebar: pad 0.32/0.42, square. */
.shell-ch-row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.55rem;
align-items: center;
grid-template-columns: 2rem minmax(0, 1fr) auto;
column-gap: 0.65rem;
align-items: start;
width: 100%;
padding: 0.32rem 0.42rem;
border-radius: 0;
color: var(--muted-foreground, var(--ui-fg-muted));
min-height: 3.75rem;
padding: 0.65rem 0.6rem;
border-radius: var(--radius);
color: var(--foreground);
text-align: left;
border: 0;
background: transparent;
cursor: pointer;
transition:
background 120ms ease,
color 120ms ease;
transition: background var(--duration-micro) var(--ease-out);
}

.shell-ch-row:hover {
background: color-mix(in srgb, var(--foreground) 5%, transparent);
color: var(--foreground, var(--ui-fg));
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}

/* Brand rule: grey is for text/structure, not container fills — the active
row stays on ground and reads through a 2px orange left edge instead of a
band. */
.shell-ch-row[aria-current="true"],
.shell-ch-row[data-active="true"] {
background: var(--background);
color: var(--shell-accent);
box-shadow: inset 2px 0 0 0 var(--shell-accent);
background: color-mix(in srgb, var(--foreground) 6%, transparent);
}

.shell-ch-row[aria-current="true"] .shell-ch-name,
.shell-ch-row[data-active="true"] .shell-ch-name {
.shell-ch-row[data-unread="true"] .shell-ch-name {
font-weight: 650;
}

/* A 5% foreground tint is a much quieter cue on a near-black row than the
same percentage on a near-white one perceptually, so dark mode's hover
gets a stronger mix to read at the same visible weight. */
.dark .shell-ch-row:hover {
background: color-mix(in srgb, var(--foreground) 9%, transparent);
}

.shell-ch-stack {
display: flex;
width: 1.35rem;
height: 1.4rem;
.shell-ch-avatar {
position: relative;
flex-shrink: 0;
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
align-self: center;
}

.shell-ch-stack span {
position: absolute;
width: 1.2rem;
height: 1.2rem;
border-radius: 50%;
border: 1.5px solid var(--background);
.shell-ch-initial {
display: grid;
place-items: center;
font-size: 0.55rem;
font-weight: 700;
/* --muted sits within ~1.2:1 of --background in dark (near-black on
pure black), leaving the chip indistinguishable from the page. A
foreground tint reads at the same visible strength in both themes
since the page it's mixed against is pure white/black either way. */
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
color: var(--foreground);
font-size: 0.75rem;
font-weight: 650;
}

.shell-ch-stack span:nth-child(1) {
left: 0;
top: 0.2rem;
z-index: 3;
.shell-ch-orbit {
position: absolute;
inset: 0;
border: 1.5px solid transparent;
border-top-color: var(--primary);
border-right-color: var(--primary);
border-bottom-color: var(--primary);
border-radius: 50%;
animation: shell-ch-orbit 1.4s linear infinite;
}

.shell-ch-stack span:nth-child(2) {
left: 0.45rem;
top: 0.2rem;
z-index: 2;
background: color-mix(in srgb, var(--foreground) 16%, transparent);
@keyframes shell-ch-orbit {
to {
transform: rotate(360deg);
}
}

.shell-ch-stack span:nth-child(3) {
left: 0.9rem;
top: 0.2rem;
z-index: 1;
.shell-ch-completion {
position: absolute;
right: -0.05rem;
bottom: -0.05rem;
display: grid;
place-items: center;
width: 0.85rem;
height: 0.85rem;
border: 2px solid var(--background);
border-radius: 50%;
background: var(--ok);
color: var(--background);
}

.shell-ch-stack span[data-agent="true"] {
background: var(--primary);
color: var(--primary-foreground);
.shell-ch-completion svg {
width: 0.6rem;
height: 0.6rem;
}

@media (prefers-reduced-motion: reduce) {
.shell-ch-orbit {
animation: none;
}
.shell-ch-row {
transition: none;
}
}

/* Agent-DM identity avatar: zero radius per brand rule, distinct from the
Expand Down Expand Up @@ -1606,7 +1606,7 @@ select:disabled,
}

.shell-ch-name {
font-weight: 580;
font-weight: 550;
font-size: 0.86rem;
overflow: hidden;
text-overflow: ellipsis;
Expand All @@ -1622,34 +1622,18 @@ select:disabled,
color: var(--muted-foreground, var(--ui-fg-muted));
}

.shell-ch-live {
width: 0.4rem;
height: 0.4rem;
border-radius: 0;
background: var(--ok);
flex-shrink: 0;
animation: shell-ch-pulse 2s ease infinite;
}

@keyframes shell-ch-pulse {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 40%, transparent);
}
50% {
box-shadow: 0 0 0 4px transparent;
}
}

.shell-ch-right {
display: flex;
min-width: 1.6rem;
flex-direction: row;
align-items: center;
gap: 0.35rem;
flex-shrink: 0;
}

.shell-ch-time {
line-height: 1.3rem;
white-space: nowrap;
font-size: 0.66rem;
color: var(--muted-foreground, var(--ui-fg-muted));
}
Expand All @@ -1658,6 +1642,7 @@ select:disabled,
workbench's message preview, or an unreachable agent's join caption. */
.shell-ch-preview {
display: block;
margin-top: 0.15rem;
font-size: 0.72rem;
color: var(--muted-foreground, var(--ui-fg-muted));
overflow: hidden;
Expand All @@ -1666,9 +1651,9 @@ select:disabled,
}

.shell-ch-row-wrap {
position: relative;
display: flex;
align-items: stretch;
gap: 0.15rem;
width: 100%;
}

Expand All @@ -1678,17 +1663,30 @@ select:disabled,
}

.shell-ch-row-menu {
position: absolute;
top: 0.5rem;
right: 0.6rem;
opacity: 0;
flex-shrink: 0;
align-self: center;
transition: opacity 120ms ease;
}

.shell-ch-row-wrap:hover .shell-ch-row-menu,
.shell-ch-row-wrap:focus-within .shell-ch-row-menu {
.shell-ch-row-wrap:focus-within .shell-ch-row-menu,
.shell-ch-row-wrap:has([aria-expanded="true"]) .shell-ch-row-menu,
.shell-ch-row-menu .chat-sidebar-row-menu-trigger {
opacity: 1;
}

.shell-ch-row-menu .chat-sidebar-row-menu-trigger::after {
inset: 0;
}

.shell-ch-row-wrap:hover .shell-ch-right,
.shell-ch-row-wrap:focus-within .shell-ch-right,
.shell-ch-row-wrap:has([aria-expanded="true"]) .shell-ch-right {
visibility: hidden;
}

/* Mock v2 .search: 2rem, square, on the ground — shared by every band. */
.shell-panel-search {
display: flex;
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/shell/workbench-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,23 @@ describe("renamePayload", () => {
});

describe("workbenchRowSignals", () => {
test("opening clears a ready reply but keeps an active orbit", () => {
expect(
workbenchRowSignals(workbench({ live: "reply-ready" }), true).live,
).toBe("idle");
expect(workbenchRowSignals(workbench({ live: "working" }), true).live).toBe(
"working",
);
});

test("passes through only the signals the platform actually sent", () => {
expect(workbenchRowSignals(workbench(), false)).toEqual({});
const signals = workbenchRowSignals(
workbench({ unreadCount: 3, live: true, sharedLabel: "Acme" }),
workbench({ unreadCount: 3, live: "working", sharedLabel: "Acme" }),
false,
);
expect(signals.unread).toBe(3);
expect(signals.live).toBe(true);
expect(signals.live).toBe("working");
expect(signals.sharedLabel).toBe("Acme");
});

Expand Down
Loading
Loading