Skip to content

Commit 7ca43c9

Browse files
committed
Improve task detail view scroll, layout, and chat styling
- Use plain div for history section to enable flex layout that pins header and input while chat scrolls via inner VscodeScrollable - Add useFollowScroll hook supporting both VscodeScrollable (scrollPos/scrollMax API) and plain scrollable divs - Extract LogEntry component with user/agent role labels and styled message groups - Restyle error banner with color-mixed --vscode-errorForeground
1 parent b4a6755 commit 7ca43c9

File tree

8 files changed

+335
-77
lines changed

8 files changed

+335
-77
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@
219219
"id": "coder.tasksPanel",
220220
"name": "Coder Tasks",
221221
"icon": "media/tasks-logo.svg",
222-
"when": "coder.authenticated && coder.tasksEnabled"
222+
"when": "coder.tasksEnabled"
223223
}
224224
]
225225
},

packages/tasks/src/App.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export default function App() {
4242
);
4343

4444
const createScrollRef = useRef<ScrollableElement>(null);
45-
const historyScrollRef = useRef<ScrollableElement>(null);
45+
const historyScrollRef = useRef<HTMLDivElement>(null);
4646
useScrollableHeight(createRef, createScrollRef);
4747
useScrollableHeight(historyRef, historyScrollRef);
4848

@@ -102,7 +102,7 @@ export default function App() {
102102
heading="Task History"
103103
open={historyOpen}
104104
>
105-
<VscodeScrollable ref={historyScrollRef}>
105+
<div ref={historyScrollRef} className="collapsible-content">
106106
{selectedTask ? (
107107
<TaskDetailView details={selectedTask} onBack={deselectTask} />
108108
) : isLoadingDetails ? (
@@ -112,7 +112,7 @@ export default function App() {
112112
) : (
113113
<TaskList tasks={tasks} onSelectTask={selectTask} />
114114
)}
115-
</VscodeScrollable>
115+
</div>
116116
</VscodeCollapsible>
117117
</div>
118118
);
Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { useEffect, useRef } from "react";
1+
import { VscodeScrollable } from "@vscode-elements/react-elements";
2+
3+
import { useFollowScroll } from "../hooks/useFollowScroll";
24

35
import type { LogsStatus, TaskLogEntry } from "@repo/shared";
46

@@ -19,58 +21,64 @@ function getEmptyMessage(logsStatus: LogsStatus): string {
1921
}
2022
}
2123

24+
function LogEntry({
25+
log,
26+
isGroupStart,
27+
}: {
28+
log: TaskLogEntry;
29+
isGroupStart: boolean;
30+
}) {
31+
return (
32+
<div className={`log-entry log-entry-${log.type}`}>
33+
{isGroupStart && (
34+
<div className="log-entry-role">
35+
{log.type === "input" ? "You" : "Agent"}
36+
</div>
37+
)}
38+
{log.content}
39+
</div>
40+
);
41+
}
42+
2243
export function AgentChatHistory({
2344
logs,
2445
logsStatus,
2546
isThinking,
2647
}: AgentChatHistoryProps) {
27-
const containerRef = useRef<HTMLDivElement>(null);
28-
const isAtBottomRef = useRef(true);
29-
30-
const handleScroll = () => {
31-
const container = containerRef.current;
32-
if (!container) return;
33-
const distanceFromBottom =
34-
container.scrollHeight - container.scrollTop - container.clientHeight;
35-
isAtBottomRef.current = distanceFromBottom <= 50;
36-
};
37-
38-
useEffect(() => {
39-
if (containerRef.current && isAtBottomRef.current) {
40-
containerRef.current.scrollTop = containerRef.current.scrollHeight;
41-
}
42-
}, [logs, isThinking]);
48+
const bottomRef = useFollowScroll();
4349

4450
return (
4551
<div className="agent-chat-history">
4652
<div className="chat-history-header">Agent chat history</div>
47-
<div
48-
className="chat-history-content"
49-
ref={containerRef}
50-
onScroll={handleScroll}
51-
>
53+
<VscodeScrollable className="chat-history-content">
5254
{logs.length === 0 ? (
5355
<div
54-
className={[
55-
"chat-history-empty",
56-
logsStatus === "error" && "chat-history-error",
57-
]
58-
.filter(Boolean)
59-
.join(" ")}
56+
className={
57+
logsStatus === "error"
58+
? "chat-history-empty chat-history-error"
59+
: "chat-history-empty"
60+
}
6061
>
6162
{getEmptyMessage(logsStatus)}
6263
</div>
6364
) : (
64-
logs.map((log) => (
65-
<div key={log.id} className="log-entry">
66-
{log.content}
67-
</div>
65+
logs.map((log, index) => (
66+
<LogEntry
67+
key={log.id}
68+
log={log}
69+
isGroupStart={isGroupStart(logs, index)}
70+
/>
6871
))
6972
)}
7073
{isThinking && (
7174
<div className="log-entry log-entry-thinking">Thinking...</div>
7275
)}
73-
</div>
76+
<div ref={bottomRef} />
77+
</VscodeScrollable>
7478
</div>
7579
);
7680
}
81+
82+
function isGroupStart(logs: TaskLogEntry[], index: number): boolean {
83+
return index === 0 || logs[index].type !== logs[index - 1].type;
84+
}

packages/tasks/src/components/TaskDetailView.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@ export function TaskDetailView({ details, onBack }: TaskDetailViewProps) {
1414
const { task, logs, logsStatus } = details;
1515

1616
const isThinking =
17-
task.status === "active" &&
18-
task.current_state?.state === "working" &&
19-
task.workspace_agent_lifecycle === "ready";
17+
task.status === "active" && task.current_state?.state === "working";
2018

2119
return (
2220
<div className="task-detail-view">
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { useEffect, useRef, type RefObject } from "react";
2+
3+
const BOTTOM_THRESHOLD = 8;
4+
5+
/**
6+
* VscodeScrollable exposes these properties on its DOM element,
7+
* but they aren't in the TypeScript definitions.
8+
*/
9+
interface ScrollableElement extends HTMLElement {
10+
scrollPos: number;
11+
scrollMax: number;
12+
}
13+
14+
function isScrollableElement(el: Element): el is ScrollableElement {
15+
return el.tagName === "VSCODE-SCROLLABLE";
16+
}
17+
18+
/**
19+
* Keeps a scroll container following new content at the bottom.
20+
* Attach the returned ref to a sentinel div at the end of scrollable content.
21+
*
22+
* Works with both VscodeScrollable (using its scrollPos/scrollMax API and
23+
* vsc-scrollable-scroll event) and plain scrollable divs.
24+
*/
25+
export function useFollowScroll(): RefObject<HTMLDivElement | null> {
26+
const ref = useRef<HTMLDivElement>(null);
27+
const atBottom = useRef(true);
28+
29+
useEffect(() => {
30+
const sentinel = ref.current;
31+
const container = sentinel?.parentElement;
32+
if (!sentinel || !container) return;
33+
34+
const isVscodeScrollable = isScrollableElement(container);
35+
36+
function isNearBottom(): boolean {
37+
if (isVscodeScrollable) {
38+
const el = container;
39+
return el.scrollMax - el.scrollPos <= BOTTOM_THRESHOLD;
40+
}
41+
return (
42+
container!.scrollHeight -
43+
container!.scrollTop -
44+
container!.clientHeight <=
45+
BOTTOM_THRESHOLD
46+
);
47+
}
48+
49+
function scrollToBottom() {
50+
if (isVscodeScrollable) {
51+
const el = container;
52+
el.scrollPos = el.scrollMax;
53+
} else {
54+
container!.scrollTop = container!.scrollHeight;
55+
}
56+
}
57+
58+
function onScroll() {
59+
atBottom.current = isNearBottom();
60+
}
61+
62+
// VscodeScrollable emits a custom event; plain divs use native scroll.
63+
const scrollEvent = isVscodeScrollable ? "vsc-scrollable-scroll" : "scroll";
64+
container.addEventListener(scrollEvent, onScroll, { passive: true });
65+
66+
// Auto-scroll when new children are added and the user was at the bottom.
67+
const mo = new MutationObserver(() => {
68+
if (atBottom.current) {
69+
scrollToBottom();
70+
}
71+
});
72+
mo.observe(container, { childList: true });
73+
74+
// Initial scroll: wait until the container has layout, then scroll to bottom.
75+
const ro = new ResizeObserver(() => {
76+
if (container.clientHeight > 0) {
77+
scrollToBottom();
78+
ro.disconnect();
79+
}
80+
});
81+
ro.observe(container);
82+
83+
return () => {
84+
container.removeEventListener(scrollEvent, onScroll);
85+
mo.disconnect();
86+
ro.disconnect();
87+
};
88+
}, []);
89+
90+
return ref;
91+
}

packages/tasks/src/hooks/useScrollableHeight.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { useEffect, type RefObject } from "react";
44
const MAX_FLEX_RATIO = 0.5;
55

66
/**
7-
* Sets an explicit pixel height on a scrollable so VscodeScrollable can
8-
* compute scroll metrics, and writes a --section-flex-grow CSS custom
9-
* property on the host for content-adaptive sizing.
7+
* Sets an explicit pixel height on a wrapper element inside a VscodeCollapsible
8+
* so it can scroll, and writes a --section-flex-grow CSS custom property on the
9+
* host for content-adaptive sizing.
1010
*/
1111
export function useScrollableHeight(
1212
hostRef: RefObject<HTMLElement | null>,

0 commit comments

Comments
 (0)