).current = bottomEl;
+
+ act(() => {
+ result.current.scrollToBottom();
+ });
+
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth" });
});
});
diff --git a/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.module.css b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.module.css
new file mode 100644
index 0000000..bc163de
--- /dev/null
+++ b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.module.css
@@ -0,0 +1,29 @@
+.root {
+ pointer-events: none;
+ opacity: 0;
+ transform: translateY(8px);
+ transition:
+ opacity 0.18s ease,
+ transform 0.18s ease,
+ border-color 0.18s ease,
+ color 0.18s ease,
+ background-color 0.18s ease;
+ background-color: var(--vscode-editorWidget-background);
+ border-color: var(--vscode-editorWidget-border, var(--vscode-panel-border));
+ box-shadow: 0 6px 16px rgb(0 0 0 / 20%);
+}
+
+.visible {
+ pointer-events: auto;
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.root:hover {
+ background-color: var(--vscode-toolbar-hoverBackground);
+}
+
+.root:focus-visible {
+ outline: 1px solid var(--vscode-focusBorder);
+ outline-offset: 1px;
+}
diff --git a/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.tsx b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.tsx
new file mode 100644
index 0000000..09e31f4
--- /dev/null
+++ b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/ScrollToBottomButton.tsx
@@ -0,0 +1,25 @@
+import { IconButton } from "../IconButton";
+import { ChevronDownIcon } from "../icons";
+import styles from "./ScrollToBottomButton.module.css";
+
+type Props = {
+ visible: boolean;
+ ariaLabel: string;
+ onClick: () => void;
+};
+
+export function ScrollToBottomButton({ visible, ariaLabel, onClick }: Props) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/index.ts b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/index.ts
new file mode 100644
index 0000000..9e8a127
--- /dev/null
+++ b/packages/platforms/vscode/webview/components/atoms/ScrollToBottomButton/index.ts
@@ -0,0 +1 @@
+export { ScrollToBottomButton } from "./ScrollToBottomButton";
diff --git a/packages/platforms/vscode/webview/components/atoms/icons/icons.tsx b/packages/platforms/vscode/webview/components/atoms/icons/icons.tsx
index 4142f23..0d43185 100644
--- a/packages/platforms/vscode/webview/components/atoms/icons/icons.tsx
+++ b/packages/platforms/vscode/webview/components/atoms/icons/icons.tsx
@@ -2,6 +2,7 @@ import type { SVGProps } from "react";
import type { IconBaseProps } from "react-icons";
import {
VscAdd,
+ VscArrowDown,
VscArrowLeft,
VscArrowRight,
VscAttach,
@@ -256,3 +257,8 @@ export function UndoIcon({ width = 16, height: _h, ...props }: IconProps) {
export function RedoIcon({ width = 16, height: _h, ...props }: IconProps) {
return ;
}
+
+/** Codicon: arrow-down — scroll to bottom */
+export function ChevronDownIcon({ width = 16, height: _h, ...props }: IconProps) {
+ return ;
+}
diff --git a/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.module.css b/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.module.css
index 2c846fc..375b043 100644
--- a/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.module.css
+++ b/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.module.css
@@ -4,6 +4,7 @@
overflow-y: auto;
overscroll-behavior-x: none;
padding: 12px;
+ position: relative;
}
.root::-webkit-scrollbar {
@@ -66,3 +67,15 @@
color: var(--vscode-foreground);
background-color: var(--vscode-toolbar-hoverBackground);
}
+
+.scrollButtonSlot {
+ position: sticky;
+ bottom: 12px;
+ display: flex;
+ justify-content: flex-end;
+ padding-right: 14px;
+ margin-top: -44px;
+ margin-bottom: 12px;
+ pointer-events: none;
+ z-index: 1;
+}
diff --git a/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.tsx b/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.tsx
index 7319ea3..ebbe1ad 100644
--- a/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.tsx
+++ b/packages/platforms/vscode/webview/components/organisms/MessagesArea/MessagesArea.tsx
@@ -3,6 +3,7 @@ import type { MessageWithParts } from "../../../App";
import { useAutoScroll } from "../../../hooks/useAutoScroll";
import { useLocale } from "../../../locales";
import { ForkIcon, RevertIcon } from "../../atoms/icons";
+import { ScrollToBottomButton } from "../../atoms/ScrollToBottomButton";
import { StreamingIndicator } from "../../atoms/StreamingIndicator";
import { MessageItem } from "../MessageItem";
import styles from "./MessagesArea.module.css";
@@ -27,7 +28,7 @@ export function MessagesArea({
onForkFromCheckpoint,
}: Props) {
const t = useLocale();
- const { containerRef, bottomRef, handleScroll } = useAutoScroll(messages);
+ const { containerRef, bottomRef, handleScroll, isNearBottom, scrollToBottom } = useAutoScroll(messages);
return (
@@ -85,6 +86,13 @@ export function MessagesArea({
);
})}
+
+ scrollToBottom()}
+ />
+
{sessionBusy && }
diff --git a/packages/platforms/vscode/webview/hooks/useAutoScroll.ts b/packages/platforms/vscode/webview/hooks/useAutoScroll.ts
index 1a7c40c..cba6766 100644
--- a/packages/platforms/vscode/webview/hooks/useAutoScroll.ts
+++ b/packages/platforms/vscode/webview/hooks/useAutoScroll.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
/** ユーザーが「最下部付近」と判定するスクロール閾値(px) */
const NEAR_BOTTOM_THRESHOLD = 100;
@@ -14,26 +14,33 @@ export function useAutoScroll(messages: unknown[]) {
const containerRef = useRef(null);
const bottomRef = useRef(null);
const isNearBottomRef = useRef(true);
+ const [isNearBottom, setIsNearBottom] = useState(true);
+
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
+ bottomRef.current?.scrollIntoView({ behavior });
+ }, []);
const handleScroll = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
- isNearBottomRef.current = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD;
+ const nextIsNearBottom = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD;
+ isNearBottomRef.current = nextIsNearBottom;
+ setIsNearBottom(nextIsNearBottom);
}, []);
// 初回マウント時に最下部へスクロールする
useEffect(() => {
- bottomRef.current?.scrollIntoView({ behavior: "smooth" });
- }, []);
+ scrollToBottom();
+ }, [scrollToBottom]);
// messages 更新時、最下部付近にいれば追従スクロールする
// biome-ignore lint/correctness/useExhaustiveDependencies: messages の参照変化を検知して effect を再実行する意図的な依存
useEffect(() => {
if (isNearBottomRef.current) {
- bottomRef.current?.scrollIntoView({ behavior: "smooth" });
+ scrollToBottom();
}
- }, [messages]);
+ }, [messages, scrollToBottom]);
- return { containerRef, bottomRef, handleScroll } as const;
+ return { containerRef, bottomRef, handleScroll, isNearBottom, scrollToBottom } as const;
}
diff --git a/packages/platforms/vscode/webview/locales/en.ts b/packages/platforms/vscode/webview/locales/en.ts
index be0c411..7803f9c 100644
--- a/packages/platforms/vscode/webview/locales/en.ts
+++ b/packages/platforms/vscode/webview/locales/en.ts
@@ -36,6 +36,7 @@ export const en = {
"checkpoint.revertTitle": "Revert to this point",
"checkpoint.retryFromHere": "Retry from here",
"checkpoint.forkFromHere": "Fork from here",
+ "scrollToBottom.ariaLabel": "Scroll to bottom",
// Undo/Redo
"header.undo": "Undo",
@@ -148,4 +149,8 @@ export const en = {
"input.noAgents": "No sub-agents available",
} as const;
+export type LocaleSchema = {
+ [K in keyof typeof en]: (typeof en)[K] extends (...args: infer Args) => string ? (...args: Args) => string : string;
+};
+
export type LocaleKeys = keyof typeof en;
diff --git a/packages/platforms/vscode/webview/locales/es.ts b/packages/platforms/vscode/webview/locales/es.ts
index 7a8a09c..def2883 100644
--- a/packages/platforms/vscode/webview/locales/es.ts
+++ b/packages/platforms/vscode/webview/locales/es.ts
@@ -1,6 +1,6 @@
-import type { en } from "./en";
+import type { LocaleSchema } from "./en";
-export const es: typeof en = {
+export const es: LocaleSchema = {
// ChatHeader
"header.sessions": "Sesiones",
"header.title.fallback": "OpenCode",
@@ -38,6 +38,7 @@ export const es: typeof en = {
"checkpoint.revertTitle": "Revertir a este punto",
"checkpoint.retryFromHere": "Reintentar desde aquí",
"checkpoint.forkFromHere": "Bifurcar desde aquí",
+ "scrollToBottom.ariaLabel": "Desplazarse al final",
// Undo/Redo
"header.undo": "Deshacer",
diff --git a/packages/platforms/vscode/webview/locales/ja.ts b/packages/platforms/vscode/webview/locales/ja.ts
index 77c2b1f..772e384 100644
--- a/packages/platforms/vscode/webview/locales/ja.ts
+++ b/packages/platforms/vscode/webview/locales/ja.ts
@@ -1,6 +1,6 @@
-import type { en } from "./en";
+import type { LocaleSchema } from "./en";
-export const ja: typeof en = {
+export const ja: LocaleSchema = {
// ChatHeader
"header.sessions": "セッション一覧",
"header.title.fallback": "OpenCode",
@@ -38,6 +38,7 @@ export const ja: typeof en = {
"checkpoint.revertTitle": "ここまで巻き戻す",
"checkpoint.retryFromHere": "ここからやり直す",
"checkpoint.forkFromHere": "ここから分岐",
+ "scrollToBottom.ariaLabel": "一番下までスクロール",
// Undo/Redo
"header.undo": "元に戻す",
diff --git a/packages/platforms/vscode/webview/locales/ko.ts b/packages/platforms/vscode/webview/locales/ko.ts
index 610c52c..c112eca 100644
--- a/packages/platforms/vscode/webview/locales/ko.ts
+++ b/packages/platforms/vscode/webview/locales/ko.ts
@@ -1,6 +1,6 @@
-import type { en } from "./en";
+import type { LocaleSchema } from "./en";
-export const ko: typeof en = {
+export const ko: LocaleSchema = {
// ChatHeader
"header.sessions": "세션 목록",
"header.title.fallback": "OpenCode",
@@ -38,6 +38,7 @@ export const ko: typeof en = {
"checkpoint.revertTitle": "이 지점으로 되돌리기",
"checkpoint.retryFromHere": "여기서 다시 시도",
"checkpoint.forkFromHere": "여기서 분기",
+ "scrollToBottom.ariaLabel": "맨 아래로 스크롤",
// Undo/Redo
"header.undo": "실행 취소",
diff --git a/packages/platforms/vscode/webview/locales/pt-br.ts b/packages/platforms/vscode/webview/locales/pt-br.ts
index 689bbe7..34d02c9 100644
--- a/packages/platforms/vscode/webview/locales/pt-br.ts
+++ b/packages/platforms/vscode/webview/locales/pt-br.ts
@@ -1,6 +1,6 @@
-import type { en } from "./en";
+import type { LocaleSchema } from "./en";
-export const ptBr: typeof en = {
+export const ptBr: LocaleSchema = {
// ChatHeader
"header.sessions": "Sessões",
"header.title.fallback": "OpenCode",
@@ -38,6 +38,7 @@ export const ptBr: typeof en = {
"checkpoint.revertTitle": "Reverter para este ponto",
"checkpoint.retryFromHere": "Tentar novamente daqui",
"checkpoint.forkFromHere": "Ramificar daqui",
+ "scrollToBottom.ariaLabel": "Rolar até o fim",
// Undo/Redo
"header.undo": "Desfazer",
diff --git a/packages/platforms/vscode/webview/locales/ru.ts b/packages/platforms/vscode/webview/locales/ru.ts
index 9aa212c..c2a5f87 100644
--- a/packages/platforms/vscode/webview/locales/ru.ts
+++ b/packages/platforms/vscode/webview/locales/ru.ts
@@ -1,6 +1,6 @@
-import type { en } from "./en";
+import type { LocaleSchema } from "./en";
-export const ru: typeof en = {
+export const ru: LocaleSchema = {
// ChatHeader
"header.sessions": "Сессии",
"header.title.fallback": "OpenCode",
@@ -38,6 +38,7 @@ export const ru: typeof en = {
"checkpoint.revertTitle": "Вернуться к этой точке",
"checkpoint.retryFromHere": "Повторить отсюда",
"checkpoint.forkFromHere": "Ответвить отсюда",
+ "scrollToBottom.ariaLabel": "Прокрутить вниз",
// Undo/Redo
"header.undo": "Отменить",
diff --git a/packages/platforms/vscode/webview/locales/zh-cn.ts b/packages/platforms/vscode/webview/locales/zh-cn.ts
index 0c1646d..1201245 100644
--- a/packages/platforms/vscode/webview/locales/zh-cn.ts
+++ b/packages/platforms/vscode/webview/locales/zh-cn.ts
@@ -1,6 +1,6 @@
-import type { en } from "./en";
+import type { LocaleSchema } from "./en";
-export const zhCn: typeof en = {
+export const zhCn: LocaleSchema = {
// ChatHeader
"header.sessions": "会话列表",
"header.title.fallback": "OpenCode",
@@ -38,6 +38,7 @@ export const zhCn: typeof en = {
"checkpoint.revertTitle": "回退到此处",
"checkpoint.retryFromHere": "从此处重试",
"checkpoint.forkFromHere": "从此处分支",
+ "scrollToBottom.ariaLabel": "滚动到底部",
// Undo/Redo
"header.undo": "撤销",
diff --git a/packages/platforms/vscode/webview/locales/zh-tw.ts b/packages/platforms/vscode/webview/locales/zh-tw.ts
index 2aec86b..218e095 100644
--- a/packages/platforms/vscode/webview/locales/zh-tw.ts
+++ b/packages/platforms/vscode/webview/locales/zh-tw.ts
@@ -1,6 +1,6 @@
-import type { en } from "./en";
+import type { LocaleSchema } from "./en";
-export const zhTw: typeof en = {
+export const zhTw: LocaleSchema = {
// ChatHeader
"header.sessions": "工作階段列表",
"header.title.fallback": "OpenCode",
@@ -38,6 +38,7 @@ export const zhTw: typeof en = {
"checkpoint.revertTitle": "回退到此處",
"checkpoint.retryFromHere": "從此處重試",
"checkpoint.forkFromHere": "從此處分支",
+ "scrollToBottom.ariaLabel": "捲動到底部",
// Undo/Redo
"header.undo": "復原",