diff --git a/packages/platforms/vscode/webview/__tests__/components/organisms/MessagesArea.test.tsx b/packages/platforms/vscode/webview/__tests__/components/organisms/MessagesArea.test.tsx index d5cde50..68cdb08 100644 --- a/packages/platforms/vscode/webview/__tests__/components/organisms/MessagesArea.test.tsx +++ b/packages/platforms/vscode/webview/__tests__/components/organisms/MessagesArea.test.tsx @@ -1,10 +1,12 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; -import { describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { MessageWithParts } from "../../../App"; import { MessagesArea } from "../../../components/organisms/MessagesArea"; import { AppContextProvider, type AppContextValue } from "../../../contexts/AppContext"; +import * as autoScrollHook from "../../../hooks/useAutoScroll"; import { createMessage, createTextPart } from "../../factories"; /** AppContext 必須の値を最小限で提供するラッパー */ @@ -40,6 +42,10 @@ const defaultProps = { describe("MessagesArea", () => { const wrapper = createContextWrapper(); + afterEach(() => { + vi.restoreAllMocks(); + }); + // when rendered with messages context("メッセージがある場合", () => { // renders message items @@ -135,5 +141,41 @@ describe("MessagesArea", () => { const scrollContainer = container.querySelector(".root") as HTMLElement; expect(scrollContainer).toBeInTheDocument(); }); + + it("最下部から離れているときにスクロールボタンを表示すること", () => { + const handleScroll = vi.fn(); + const scrollToBottom = vi.fn(); + + vi.spyOn(autoScrollHook, "useAutoScroll").mockReturnValue({ + containerRef: { current: null }, + bottomRef: { current: null }, + handleScroll, + isNearBottom: false, + scrollToBottom, + }); + + render(, { wrapper }); + + expect(screen.getByLabelText("Scroll to bottom")).toBeInTheDocument(); + }); + + it("スクロールボタンクリックで scrollToBottom を呼ぶこと", async () => { + const handleScroll = vi.fn(); + const scrollToBottom = vi.fn(); + + vi.spyOn(autoScrollHook, "useAutoScroll").mockReturnValue({ + containerRef: { current: null }, + bottomRef: { current: null }, + handleScroll, + isNearBottom: false, + scrollToBottom, + }); + + render(, { wrapper }); + + await userEvent.click(screen.getByLabelText("Scroll to bottom")); + + expect(scrollToBottom).toHaveBeenCalledWith(); + }); }); }); diff --git a/packages/platforms/vscode/webview/__tests__/hooks/useAutoScroll.test.ts b/packages/platforms/vscode/webview/__tests__/hooks/useAutoScroll.test.ts index 5414cf1..8121ced 100644 --- a/packages/platforms/vscode/webview/__tests__/hooks/useAutoScroll.test.ts +++ b/packages/platforms/vscode/webview/__tests__/hooks/useAutoScroll.test.ts @@ -17,6 +17,16 @@ describe("useAutoScroll", () => { const { result } = renderHook(() => useAutoScroll([])); expect(typeof result.current.handleScroll).toBe("function"); }); + + it("isNearBottom がデフォルトで true であること", () => { + const { result } = renderHook(() => useAutoScroll([])); + expect(result.current.isNearBottom).toBe(true); + }); + + it("scrollToBottom コールバックを返すこと", () => { + const { result } = renderHook(() => useAutoScroll([])); + expect(typeof result.current.scrollToBottom).toBe("function"); + }); }); // on mount @@ -97,6 +107,7 @@ describe("useAutoScroll", () => { rerender({ messages: ["msg1", "msg2"] }); expect(scrollIntoViewMock).not.toHaveBeenCalled(); + expect(result.current.isNearBottom).toBe(false); }); }); @@ -137,6 +148,24 @@ describe("useAutoScroll", () => { // メッセージ更新 rerender({ messages: ["msg1", "msg2", "msg3"] }); + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth" }); + expect(result.current.isNearBottom).toBe(true); + }); + }); + + context("scrollToBottom を明示的に呼び出した場合", () => { + it("scrollIntoView が呼ばれること", () => { + const scrollIntoViewMock = vi.fn(); + const { result } = renderHook(() => useAutoScroll([])); + + const bottomEl = document.createElement("div"); + bottomEl.scrollIntoView = scrollIntoViewMock; + (result.current.bottomRef as React.MutableRefObject).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": "復原",