Skip to content
Merged
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
18 changes: 9 additions & 9 deletions apps/desktop/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2220,21 +2220,21 @@ export function Sidebar({
{isRemoteSession(session) && <span className="thread-item-source" title={session.remoteHostLabel}>{t("remote:remote")}</span>}
<WorktreeBadge worktree={session.worktree} sessionId={session.id} />
<span className="thread-item-title">{taskTitle(session.title)}</span>
{lastReplyDate ? (
<>
<span className="thread-item-date-separator" aria-hidden="true">
|
</span>
<span className="thread-item-last-reply">{lastReplyDate}</span>
</>
) : null}
{options?.global ? (
<span className="thread-item-project">
{owningProject}
</span>
) : null}
</button>
<div className="sidebar-row-actions">
<div
className={cx(
"thread-item-meta",
sessionMenu === session.id && "is-menu-open",
)}
>
{lastReplyDate ? (
<span className="thread-item-last-reply">{lastReplyDate}</span>
) : null}
<TooltipButton
type="button"
className="thread-item-more"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/TodoDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function TodoDock({ sessionId }: { sessionId: string }) {
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
>
{expanded ? <IconChevronDown size={14} /> : <IconChevronUp size={14} />}
{expanded ? <IconChevronDown size={18} /> : <IconChevronUp size={18} />}
</Button>
</div>
<div id={contentId} className="todo-dock-content" aria-hidden={!expanded}>
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/lib/sidebar-session-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ export function formatSessionLastReplyDate(
}

return new Intl.DateTimeFormat(locale || undefined, {
dateStyle: "medium",
month: "short",
day: "numeric",
}).format(date);
} catch {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date);
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
}).format(date);
}
}
14 changes: 7 additions & 7 deletions apps/desktop/src/styles/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -737,19 +737,19 @@
display: flex;
width: 100%;
min-width: 0;
min-height: 38px;
min-height: 44px;
align-items: center;
gap: 10px;
padding: 5px 8px 5px 12px;
padding: 4px 8px 4px 12px;
color: var(--ds-text-primary);
text-align: left;
}

.todo-dock-toggle {
width: 28px;
min-width: 28px;
height: 28px;
flex: 0 0 28px;
.todo-dock-toggle.btn {
width: 36px;
min-width: 36px;
height: 36px;
flex: 0 0 36px;
padding: 0;
color: var(--ds-text-muted);
}
Expand Down
15 changes: 12 additions & 3 deletions apps/desktop/src/styles/sessions.css
Original file line number Diff line number Diff line change
Expand Up @@ -406,14 +406,18 @@
color: var(--ds-accent);
}

.sidebar-row-actions,
.sidebar-menu-wrap {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}

.thread-item-meta {
position: relative;
flex: 0 0 auto;
}

.thread-item-more {
display: inline-flex;
width: 24px;
Expand All @@ -432,6 +436,7 @@
}

.thread-item:hover .thread-item-more,
.thread-item:focus-within .thread-item-more,
.sidebar-session-group-header:hover .thread-item-more,
.sidebar-session-group-header:focus-within .thread-item-more,
.thread-item.active .thread-item-more:hover,
Expand All @@ -440,7 +445,6 @@
pointer-events: auto;
}

.thread-item:focus-within .thread-item-more,
.thread-item-more:focus-visible {
opacity: 1;
pointer-events: auto;
Expand All @@ -455,12 +459,17 @@
/* Without hover there is nothing to reveal the row controls with, so a no-hover
* pointer gets them visible and tappable outright. */
@media (hover: none) {
.sidebar-row-actions .thread-item-more,
.thread-item-meta .thread-item-more,
.sidebar-menu-wrap .thread-item-more,
.sidebar-session-group-add {
opacity: 1;
pointer-events: auto;
}

.thread-item-meta .thread-item-last-reply {
opacity: 0;
visibility: hidden;
}
}

.thread-item-more:hover,
Expand Down
32 changes: 23 additions & 9 deletions apps/desktop/src/styles/sidebar-threads.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,37 @@
white-space: nowrap;
}

.thread-item-date-separator,
.thread-item-last-reply {
.thread-item-meta {
/* The action and date overlap in one slot; opacity keeps its width stable. */
display: grid;
width: max-content;
flex: 0 0 auto;
align-items: center;
justify-items: end;
}

.thread-item-meta > * {
grid-area: 1 / 1;
}

.thread-item-last-reply {
color: var(--ds-text-muted);
font-size: var(--text-sm);
white-space: nowrap;
transition:
opacity var(--motion-duration-fast) var(--motion-ease-out),
visibility var(--motion-duration-fast) var(--motion-ease-out);
}

.thread-item-date-separator {
margin-left: auto;
}

.thread-item:hover .thread-item-date-separator,
.thread-item:hover .thread-item-last-reply,
.thread-item:focus-within .thread-item-date-separator,
.thread-item:focus-within .thread-item-last-reply {
display: none;
opacity: 0;
visibility: hidden;
}

.thread-item-meta.is-menu-open .thread-item-last-reply {
opacity: 0;
visibility: hidden;
}

.thread-item:hover,
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/test/sidebar-navigation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -368,13 +368,17 @@ test("hidden row actions stay out of the row's click path", () => {
);
assert.match(
globalStyles,
/\.thread-item:focus-within \.thread-item-more,\s*\n\.thread-item-more:focus-visible\s*\{[^}]*pointer-events:\s*auto;/s,
/\.thread-item:hover \.thread-item-more,\s*\n\.thread-item:focus-within \.thread-item-more,[\s\S]*?\{[^}]*pointer-events:\s*auto;/s,
);
assert.match(
globalStyles,
/\.thread-item-more:focus-visible\s*\{[^}]*opacity:\s*1;[^}]*pointer-events:\s*auto;/s,
);
// Without hover there is no reveal, so a no-hover pointer gets the controls
// visible and tappable instead of an invisible gutter.
assert.match(
globalStyles,
/@media \(hover: none\)\s*\{[\s\S]*?\.sidebar-row-actions \.thread-item-more,[\s\S]*?opacity:\s*1;\s*\n\s*pointer-events:\s*auto;/,
/@media \(hover: none\)\s*\{[\s\S]*?\.thread-item-meta \.thread-item-more,[\s\S]*?opacity:\s*1;\s*\n\s*pointer-events:\s*auto;/,
);
// The row itself stays clickable where the hidden control used to swallow
// the click, and spelled-out controls never double-fire the row.
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/test/sidebar-pinned-rendering.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ test("sidebar renders global pins once, outside project folding and history limi
activeSessionId: null,
selectingSessionId: null,
page: "chat",
settings: { sidebarSessionTime: true },
projectCollapsed: {},
sessionView: { sort: "recent", archived: false },
};
Expand Down Expand Up @@ -152,8 +153,7 @@ test("sidebar renders global pins once, outside project folding and history limi
const datedGroup =
unpinned.match(/data-sidebar-project-group="\/dated"[\s\S]*?<\/section>/)?.[0] ?? "";
assert.deepEqual(rows(datedGroup), ["date-row"]);
assert.match(datedGroup, /class="thread-item-date-separator" aria-hidden="true">\|<\/span>/);
assert.match(datedGroup, /class="thread-item-last-reply">Jan 1, 2020<\/span>/);
assert.match(datedGroup, /class="thread-item-meta(?: is-menu-open)?"[\s\S]*?class="thread-item-last-reply">Jan 1<\/span>/);

// A retained project with no sessions is the empty state, and it has to sit
// in the same list layer so the fold animates it away too.
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/test/sidebar-session-date.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ test("formats session activity through seven calendar days and uses a date after
);
assert.equal(
formatSessionLastReplyDate(atLocalDay(8), now, "en"),
"Oct 8, 2026",
"Oct 8",
);
});

Expand All @@ -38,11 +38,11 @@ test("invalid timestamps do not render a misleading date", () => {

test("project session rows show the date until hover or focus reveals actions", () => {
assert.match(sidebarSource, /sidebarSessionTime === true/);
assert.match(sidebarSource, /className="thread-item-date-separator"/);
assert.match(sidebarSource, /className=\{cx\([\s\S]*?"thread-item-meta"[\s\S]*?sessionMenu === session\.id/);
assert.match(sidebarSource, /className="thread-item-last-reply"/);
assert.match(
sidebarStyles,
/\.thread-item:hover \.thread-item-last-reply,[\s\S]*?\.thread-item:focus-within \.thread-item-last-reply\s*\{\s*display:\s*none;/,
/\.thread-item:hover \.thread-item-last-reply,[\s\S]*?\.thread-item:focus-within \.thread-item-last-reply\s*\{\s*opacity:\s*0;\s*visibility:\s*hidden;/,
);
assert.match(
sidebarStyles,
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/test/todo-dock-rendering.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ test("TodoDock renders bounded session progress and cancelled state", async () =
assert.doesNotMatch(html, /more items/);
assert.match(html, /class="todo-dock-list" role="list" tabindex="-1"/);
assert.match(html, /lucide-chevron-up/);
assert.match(
html,
/<svg(?=[^>]*class="[^"]*lucide-chevron-up")(?=[^>]*width="18")(?=[^>]*height="18")[^>]*>/,
);
assert.match(html, /lucide-check/);
// The sweep rides the text of the working step only, and only once: while
// collapsed it is the header's current-item line, because the list stays
Expand Down Expand Up @@ -98,6 +102,10 @@ test("TodoDock renders bounded session progress and cancelled state", async () =
test("TodoDock uses a quiet disclosure animation and state surfaces", async () => {
const { readFile } = await import("node:fs/promises");
const css = await readFile(new URL("../src/styles/composer.css", import.meta.url), "utf8");
assert.match(
css,
/\.todo-dock-toggle\.btn\s*\{[^}]*width:\s*36px;[^}]*min-width:\s*36px;[^}]*height:\s*36px;[^}]*flex:\s*0 0 36px;[^}]*padding:\s*0;/,
);
assert.match(css, /\.todo-dock-toggle:active:not\(:disabled\)\s*\{[\s\S]*?transform:\s*none;/);
assert.match(css, /\.todo-dock-header-copy\s*\{[\s\S]*?flex:\s*1;[\s\S]*?align-items:\s*center;/);
assert.match(css, /\.todo-dock-row-completed \.todo-dock-row-content\s*\{[\s\S]*?text-decoration:\s*line-through;/);
Expand Down
8 changes: 5 additions & 3 deletions docs/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,7 @@ visually distinct from list content.
| Session completed | Green check mark from the latest unread task notification when the row is not selected |
| Session failed | Red circled alert mark from the latest unread task notification when the row is not selected |
| Hover row | Project headers and all conversation rows share one full-row `--ds-bg-hover` surface, radius and transition; selected conversation fill takes precedence |
| Project session metadata | When Session time is enabled, the trailing slot shows the last-reply date at rest and replaces it with session actions on hover or keyboard focus, without a divider. Dates within seven calendar days are relative; older dates show localized month and day without a year |
| Current workspace | Project header shows the workspace dot, never a persistent selection fill; topbar follows that workspace; composer exposes no workspace identity |
| Navigation selection | Only the selected conversation on the chat page uses `--ds-bg-active`, including pinned and standalone rows; folding its group never transfers selection to the header. No conversation or a non-chat page means no selected conversation row |
| Keyboard focus | The focused control keeps its visible outline; the project title stays transparent and independent action buttons retain their own hover feedback |
Expand Down Expand Up @@ -739,7 +740,7 @@ controls.
| Project archive | omitted from default view; restorable from archived view |
| Project delete | the row-menu danger action always opens a confirmation dialog naming the project and session count; X, Cancel, Escape, and backdrop dismiss without changes; explicit confirmation stops listed running sessions before removing the durable project row, owned sessions/transcripts, and project memory; the folder is never deleted; deleting a root of a stored multi-folder project group is refused until the root is detached through Edit project. Chats on a detached root remain available as a standalone project. A path the host no longer knows is still removed from the list. |
| Project memory | row-menu editor reads and saves a compact list of titled or untitled memory cards for the exact project path; cards can be added, edited, and removed, the context is available in later chats, and it is never a higher-priority instruction |
| Session list | exact-path matches only; no basename grouping; the latest ten unpinned sessions appear initially without time-group headings, and Load more reveals the remaining history; each project row shows the title, a divider, and a localized last-reply age through seven calendar days or a concrete date afterward; hovering or focusing the row replaces that date with session actions; collapsing the project clears the expanded-list state so the next expansion returns to the initial ten |
| Session list | exact-path matches only; no basename grouping; the latest ten unpinned sessions appear initially without time-group headings, and Load more reveals the remaining history; when Session time is enabled, each project row shows the title and a localized last-reply age through seven calendar days or a month/day date afterward; hovering or focusing the row replaces that date with session actions in the same slot; collapsing the project clears the expanded-list state so the next expansion returns to the initial ten |
| Active group | exactly one group reflects the selected host workspace |
| Task state | In-progress, selected, completed, and failed indicators update by session without replacing the visible transcript; precedence is in-progress, selected, then terminal outcome |

Expand Down Expand Up @@ -2954,8 +2955,9 @@ reasoning-level control.
places an independent expand/collapse button at the right edge. Only that
button toggles the list; the text area is not interactive. Its arrow points
up while collapsed because the list opens upward from the Composer, then
points down while expanded. The current task title truncates when space is
limited.
points down while expanded. The button provides a 36px square hit target,
with its 18px arrow icon sized independently. The current task title truncates
when space is limited.
A checklist whose items are all cancelled has a clear cancelled label
instead of a misleading `0/0 completed` count.
- The disclosure is keyboard accessible, does not take focus on updates, and
Expand Down
14 changes: 10 additions & 4 deletions docs/spec/04-ux/09-interaction-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,11 @@ may be retained while exactly one workspace supplies the visible shell context.
a stable path order until they are moved.
- Each project group shows the ten most-recent rows in the active sort order
by default; the remaining sessions fold behind a **Load N more…** control.
The expanded list remains flat. Each project session row shows its title and
localized last-reply age through seven calendar days, then a concrete date;
hover or keyboard focus replaces the date with the session actions. Expanded
state is per-group, for the current session only, and not persisted.
The expanded list remains flat. When Session time is enabled, each project
session row shows its title and localized last-reply age through seven
calendar days, then a month/day date; hover or keyboard focus replaces the
date with the session actions. Expanded state is per-group, for the current
session only, and not persisted.
- Presentation changes are saved best-effort. Storage failure must not block
project activation, session selection, or agent execution.

Expand Down Expand Up @@ -1600,6 +1601,11 @@ Project drag/drop follows these patterns:
and remain reachable through keyboard focus (`:focus-within` /
`:focus-visible`) without a pointer. A spelled-out control never triggers the
row or header underneath it as well.
- When Session time is enabled, project session dates and the overflow action
share one trailing slot: the date is visible at rest, then yields that slot
to the action on hover or keyboard focus. Dates through seven calendar days
use a relative day label; older dates use the localized month and day without
a year. On devices without hover, the action stays available in that slot.
- The hover card's own navigation controls are the only interactive surfaces
inside the card; the row keeps receiving pointer events everywhere else on
it.
Expand Down
21 changes: 14 additions & 7 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -10048,9 +10048,10 @@ must keep splitting are covered by `markdown-blocks.test.mjs`.
- **Steps**: Start a multi-step Agent turn that calls `TodoWrite` with ordered
pending and `in_progress` items. Observe the TodoDock above the Composer;
confirm its progress text is left-aligned, its separate arrow button is at
the right edge, and clicking the text does not expand it. Click the upward
arrow to expand, then confirm the arrow points down. Switch sessions and
confirm the checklist stays session-scoped.
the right edge, measures at least 36px square, and contains an independently
sized 18px arrow icon. Confirm clicking the text does not expand it. Click
the upward arrow to expand, then confirm the arrow points down. Switch
sessions and confirm the checklist stays session-scoped.
Expand a checklist longer than the dock's height cap: every row must render
and the list must scroll inside the dock without growing the Composer stack.
Complete and cancel items, confirm the all-cancelled label, then clear the
Expand Down Expand Up @@ -16197,12 +16198,15 @@ plugin-form fixtures in an isolated temporary directory at runtime.
#### E2E-LAYOUT-sidebar-row-states

- **Preconditions**: Host-seeded project, pinned and standalone conversations;
a current workspace; built desktop with isolated data and profile directories.
the sidebar Session time preference enabled; a current workspace; built
desktop with isolated data and profile directories.
- **Steps**: In dark and light themes, select a project conversation, hover its
project title and an unselected conversation, then hover the selected row.
Check that a project session date is visible at rest and replaced by its
action on hover and focus. Exercise the window-blur handler, drop-target
styling, project action hover and keyboard Tab/Shift+Tab focus. Fold and
action in the same trailing slot on hover and focus; verify no divider appears
before the date, dates through seven calendar days are relative, and older
dates show month and day without a year. Exercise the window-blur handler,
drop-target styling, project action hover and keyboard Tab/Shift+Tab focus. Fold and
reopen its group. Select pinned and
standalone conversations. Open Settings and return. Enable reduced motion and
inspect both row transition durations. In a separate twelve-session project,
Expand All @@ -16217,7 +16221,10 @@ plugin-form fixtures in an isolated temporary directory at runtime.
wins over hover, and blur releases hover without clearing selection. Settings
replaces sidebar navigation, and returning restores conversation and workspace
context without a second selected row. Project session rows show dates at rest
and reveal actions in their place on hover and focus. Loading more sessions
in the same trailing slot used for session actions, with no divider. Hover and
focus replace the date with the action in that slot. Dates through seven
calendar days are relative and older dates show month and day without a year.
Loading more sessions
reveals all twelve; folding and reopening restores the initial ten and the
two-session remainder control. Rendered component tests additionally cover no
selected session, a pending destination and non-chat page state.
Expand Down
Loading
Loading