Skip to content

Commit c4c6065

Browse files
authored
feat(app): show todowrite todos as a pill above the composer (#59092)
1 parent 4703b46 commit c4c6065

10 files changed

Lines changed: 537 additions & 37 deletions

File tree

‎packages/app/e2e/regression/settings.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ for (const locale of ["en", "ar"]) {
188188
await expect
189189
.poll(async () => {
190190
const anchor = await capsule.boundingBox()
191-
const container = await page.locator('[data-component="session-activity-chips"]').boundingBox()
191+
const container = await page.locator('[data-component="session-composer-lane"]').boundingBox()
192192
if (!anchor || !container) return Infinity
193193
return Math.abs(
194194
value === "Center"

‎packages/app/src/runtime/i18n/en.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,10 @@ export const dict = {
606606
"session.queue.reverted": "Redo the revert before you reorder or edit queued prompts",
607607
"session.queue.attachments.one": "{{count}} attachment",
608608
"session.queue.attachments.other": "{{count}} attachments",
609+
"session.todo.title": "Todos",
610+
"session.todo.progress": "{{done}}/{{total}}",
611+
"session.todo.label.one": "{{done}} of {{count}} todo done",
612+
"session.todo.label.other": "{{done}} of {{count}} todos done",
609613
"session.timeline.working": "Working",
610614
"session.running.stop.subagent": "Interrupt subagent",
611615
"session.running.stop.shell": "Kill shell command",

‎packages/app/src/session/composer/activity-chips.tsx‎

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { Icon } from "@opencode/ui/icon"
22
import { IconButton } from "@opencode/ui/icon-button"
33
import { Menu } from "@opencode/ui/menu"
44
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
5-
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
5+
import { createEffect, createSignal, For, onCleanup, Show } from "solid-js"
66
import { useLanguage } from "@/runtime/i18n/language"
77
import { useSettings } from "@/settings/model"
8+
import { useComposerLaneAlignment } from "./composer-lane"
89
import "./activity-chips.css"
910

1011
export type SessionActivityItem = {
@@ -42,8 +43,7 @@ export function formatElapsed(ms: number, t: (key: DurationKey, params: Record<s
4243
})
4344
}
4445

45-
// Live work above the composer, one capsule like the iOS app's background-work summary. It floats out of flow in a
46-
// lane the timeline reserves at its tail, so coming and going never resizes the composer region or moves the timeline.
46+
// Live work above the composer, one capsule like the iOS app's background-work summary, in the composer lane.
4747
export function SessionActivityChips(props: {
4848
subagents: readonly SessionActivityItem[]
4949
shells: readonly SessionActivityItem[]
@@ -56,16 +56,9 @@ export function SessionActivityChips(props: {
5656
const active = () =>
5757
settings.general.backgroundActivity() !== "off" && (props.subagents.length > 0 || props.shells.length > 0)
5858

59-
const alignment = createMemo(() => {
60-
const value = settings.general.backgroundActivity()
59+
const alignment = useComposerLaneAlignment()
6160

62-
if (value === "center") return "center"
63-
64-
// Left/right are physical positions; Kobalte and flex alignment use logical start/end.
65-
return (value === "left") !== (language.direction() === "rtl") ? "start" : "end"
66-
})
67-
68-
const placement = () => ({ center: "top", start: "top-start", end: "top-end" } as const)[alignment()]
61+
const placement = () => (({ center: "top", start: "top-start", end: "top-end" }) as const)[alignment()]
6962

7063
// A subagent can only be interrupted once its child session exists.
7164
const stoppable = (item: SessionActivityItem) => item.type === "shell" || !!item.sessionID
@@ -143,15 +136,7 @@ export function SessionActivityChips(props: {
143136

144137
return (
145138
<Show when={active()}>
146-
<div
147-
data-component="session-activity-chips"
148-
class="pointer-events-none absolute inset-x-0 bottom-full flex items-center pb-2.5"
149-
classList={{
150-
"justify-start": alignment() === "start",
151-
"justify-center": alignment() === "center",
152-
"justify-end": alignment() === "end",
153-
}}
154-
>
139+
<div data-component="session-activity-chips" class="flex min-w-0 shrink items-center">
155140
<Menu gutter={8} placement={placement()} onOpenChange={setOpen}>
156141
<Menu.Trigger
157142
as="button"
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { createMemo, type ParentProps } from "solid-js"
2+
import { useLanguage } from "@/runtime/i18n/language"
3+
import { useSettings } from "@/settings/model"
4+
5+
/** Where the floating pills above the composer sit, in logical terms, following the background activity setting. */
6+
export function useComposerLaneAlignment() {
7+
const language = useLanguage()
8+
const settings = useSettings()
9+
10+
return createMemo(() => {
11+
const value = settings.general.backgroundActivity()
12+
13+
if (value === "center") return "center"
14+
15+
// Left/right are physical positions; Kobalte and flex alignment use logical start/end.
16+
return (value === "right") === (language.direction() === "rtl") ? "start" : "end"
17+
})
18+
}
19+
20+
// The pills float out of flow in a lane the timeline reserves at its tail, so coming and going never resizes the
21+
// composer region or moves the timeline. Every pill shares the lane's one row.
22+
export function SessionComposerLane(props: ParentProps) {
23+
const alignment = useComposerLaneAlignment()
24+
25+
return (
26+
<div
27+
data-component="session-composer-lane"
28+
data-alignment={alignment()}
29+
class="pointer-events-none absolute inset-x-0 bottom-full flex items-end gap-2 pb-2.5"
30+
classList={{
31+
"justify-start": alignment() === "start",
32+
"justify-center": alignment() === "center",
33+
"justify-end": alignment() === "end",
34+
}}
35+
>
36+
{props.children}
37+
</div>
38+
)
39+
}

‎packages/app/src/session/composer/region.tsx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ import { SessionComposerRegion } from "./session-composer-region"
3535
import { createSessionComposerController, type SessionComposerController } from "./controller"
3636
import { SessionQueuePanel } from "./queue-panel"
3737
import { SessionActivityChips, type SessionActivityItem } from "./activity-chips"
38+
import { SessionComposerLane } from "./composer-lane"
39+
import { SessionTodoPill } from "./todo-pill"
40+
import { sessionTodos, todoFinished } from "./todos"
3841
import { useOpenSessionRoute } from "../session-identity-header"
3942
import { resolveSessionComposerSelection } from "./selection"
4043
import { composerProviderName } from "@/composer/selection"
@@ -407,6 +410,18 @@ export function ActiveSessionComposerRegion(props: {
407410
.flatMap((task) => (task.type === "shell" ? [{ ...task, key: task.id, target: task.id }] : [])),
408411
)
409412

413+
const todos = createMemo(() => sessionTodos(props.session.history.messages(), props.session.data.revertMessageID()))
414+
// Like the old dock: the plan shows while the turn runs and still has work left, so stale todos never linger.
415+
const todosVisible = () =>
416+
todoEnabled() && props.session.data.working() && todos().length > 0 && !todos().every(todoFinished)
417+
// Matches the server: the highest-priority document that sets `experimental` decides. Migrated V1 sessions keep their
418+
// old `todowrite` calls, so without this check their last plan would come back.
419+
const todoEnabled = () =>
420+
data.location.config
421+
.list({ directory: location().directory })
422+
?.flatMap((entry) => (entry.type === "document" && entry.info.experimental ? [entry.info.experimental] : []))
423+
.at(-1)?.todo === true
424+
410425
const open = (item: SessionActivityItem) => {
411426
const current = props.session.identity.params.id
412427

@@ -476,7 +491,12 @@ export function ActiveSessionComposerRegion(props: {
476491
}
477492
above={
478493
<Show when={!missing() && !props.session.data.isChild()}>
479-
<SessionActivityChips subagents={subagents()} shells={shells()} onOpen={open} onStop={stop} />
494+
<SessionComposerLane>
495+
<SessionActivityChips subagents={subagents()} shells={shells()} onOpen={open} onStop={stop} />
496+
<Show when={todosVisible()}>
497+
<SessionTodoPill todos={todos()} />
498+
</Show>
499+
</SessionComposerLane>
480500
<SessionQueuePanel queue={props.model.queue} hidden={props.model.region.state.blocked()} />
481501
</Show>
482502
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
[data-color-scheme="dark"] [data-slot="session-todo-pill"] {
2+
background: var(--v2-background-bg-layer-01);
3+
}
4+
5+
/* The activity capsule's overlays and focus ring. */
6+
[data-slot="session-todo-pill"] {
7+
&:focus-visible {
8+
outline: 2px solid var(--v2-border-border-focus);
9+
outline-offset: 2.5px;
10+
}
11+
12+
@media (hover: hover) {
13+
&:hover:not(:active, [data-expanded]) {
14+
background-image: linear-gradient(var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover));
15+
}
16+
}
17+
18+
&:is(:active, [data-expanded]) {
19+
background-image: linear-gradient(
20+
var(--v2-overlay-simple-overlay-pressed),
21+
var(--v2-overlay-simple-overlay-pressed)
22+
);
23+
}
24+
}
25+
26+
[data-slot="session-todo-panel"] {
27+
width: 320px;
28+
max-width: calc(100vw - 56px);
29+
padding-top: 6px;
30+
border: 0.5px solid var(--v2-border-border-muted);
31+
border-radius: 10px;
32+
background: var(--v2-background-bg-layer-01);
33+
box-shadow: var(--v2-elevation-floating);
34+
transform-origin: var(--kb-popover-content-transform-origin);
35+
animation: session-todo-panel-in 150ms ease-out;
36+
}
37+
38+
@keyframes session-todo-panel-in {
39+
from {
40+
opacity: 0;
41+
transform: translateY(4px) scale(0.98);
42+
}
43+
}
44+
45+
[data-slot="session-todo-glyph"] {
46+
color: var(--v2-icon-icon-faint);
47+
48+
&[data-status="in_progress"] {
49+
color: var(--v2-icon-icon-accent);
50+
}
51+
52+
&[data-status="completed"] {
53+
color: var(--v2-icon-icon-muted);
54+
}
55+
}
56+
57+
[data-slot="session-todo-pulse"] {
58+
transform-box: fill-box;
59+
transform-origin: center;
60+
animation: session-todo-pulse 1.6s ease-in-out infinite;
61+
}
62+
63+
@keyframes session-todo-pulse {
64+
50% {
65+
transform: scale(0.6);
66+
opacity: 0.55;
67+
}
68+
}
69+
70+
[data-slot="session-todo-row"] {
71+
&[data-status="in_progress"] {
72+
background: var(--v2-overlay-simple-overlay-hover);
73+
74+
[data-slot="session-todo-text"] {
75+
font-weight: 530;
76+
}
77+
}
78+
79+
&:is([data-status="completed"], [data-status="cancelled"]) [data-slot="session-todo-text"] {
80+
color: var(--v2-text-text-faint);
81+
}
82+
83+
&[data-status="cancelled"] [data-slot="session-todo-text"] {
84+
text-decoration: line-through;
85+
}
86+
}
87+
88+
:root[data-motion="reduce"] :is([data-slot="session-todo-pulse"], [data-slot="session-todo-panel"]) {
89+
animation: none;
90+
}
91+
92+
@media (prefers-reduced-motion: reduce) {
93+
[data-slot="session-todo-pulse"],
94+
[data-slot="session-todo-panel"] {
95+
animation: none;
96+
}
97+
}
98+
99+
@media (hover: none) {
100+
[data-slot="session-todo-pill"] {
101+
min-height: 44px;
102+
}
103+
}

0 commit comments

Comments
 (0)