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
Original file line number Diff line number Diff line change
@@ -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 必須の値を最小限で提供するラッパー */
Expand Down Expand Up @@ -40,6 +42,10 @@ const defaultProps = {
describe("MessagesArea", () => {
const wrapper = createContextWrapper();

afterEach(() => {
vi.restoreAllMocks();
});

// when rendered with messages
context("メッセージがある場合", () => {
// renders message items
Expand Down Expand Up @@ -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(<MessagesArea {...defaultProps} />, { 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(<MessagesArea {...defaultProps} />, { wrapper });

await userEvent.click(screen.getByLabelText("Scroll to bottom"));

expect(scrollToBottom).toHaveBeenCalledWith();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,6 +107,7 @@ describe("useAutoScroll", () => {
rerender({ messages: ["msg1", "msg2"] });

expect(scrollIntoViewMock).not.toHaveBeenCalled();
expect(result.current.isNearBottom).toBe(false);
});
});

Expand Down Expand Up @@ -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<HTMLDivElement | null>).current = bottomEl;

act(() => {
result.current.scrollToBottom();
});

expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth" });
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 (
<IconButton
variant="outlined"
className={[styles.root, visible && styles.visible].filter(Boolean).join(" ")}
aria-label={ariaLabel}
aria-hidden={!visible}
title={ariaLabel}
tabIndex={visible ? 0 : -1}
onClick={onClick}
>
<ChevronDownIcon width={16} />
</IconButton>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ScrollToBottomButton } from "./ScrollToBottomButton";
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { SVGProps } from "react";
import type { IconBaseProps } from "react-icons";
import {
VscAdd,
VscArrowDown,
VscArrowLeft,
VscArrowRight,
VscAttach,
Expand Down Expand Up @@ -256,3 +257,8 @@ export function UndoIcon({ width = 16, height: _h, ...props }: IconProps) {
export function RedoIcon({ width = 16, height: _h, ...props }: IconProps) {
return <VscArrowRight {...adapt({ width, ...props }, 16)} />;
}

/** Codicon: arrow-down — scroll to bottom */
export function ChevronDownIcon({ width = 16, height: _h, ...props }: IconProps) {
return <VscArrowDown {...adapt({ width, ...props }, 16)} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
overflow-y: auto;
overscroll-behavior-x: none;
padding: 12px;
position: relative;
}

.root::-webkit-scrollbar {
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<div ref={containerRef} className={styles.root} onScroll={handleScroll}>
Expand Down Expand Up @@ -85,6 +86,13 @@ export function MessagesArea({
</div>
);
})}
<div className={styles.scrollButtonSlot}>
<ScrollToBottomButton
visible={!isNearBottom}
ariaLabel={t["scrollToBottom.ariaLabel"]}
onClick={() => scrollToBottom()}
/>
</div>
{sessionBusy && <StreamingIndicator />}
<div ref={bottomRef} />
</div>
Expand Down
21 changes: 14 additions & 7 deletions packages/platforms/vscode/webview/hooks/useAutoScroll.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";

/** ユーザーが「最下部付近」と判定するスクロール閾値(px) */
const NEAR_BOTTOM_THRESHOLD = 100;
Expand All @@ -14,26 +14,33 @@ export function useAutoScroll(messages: unknown[]) {
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(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;
}
5 changes: 5 additions & 0 deletions packages/platforms/vscode/webview/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
5 changes: 3 additions & 2 deletions packages/platforms/vscode/webview/locales/es.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions packages/platforms/vscode/webview/locales/ja.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -38,6 +38,7 @@ export const ja: typeof en = {
"checkpoint.revertTitle": "ここまで巻き戻す",
"checkpoint.retryFromHere": "ここからやり直す",
"checkpoint.forkFromHere": "ここから分岐",
"scrollToBottom.ariaLabel": "一番下までスクロール",

// Undo/Redo
"header.undo": "元に戻す",
Expand Down
5 changes: 3 additions & 2 deletions packages/platforms/vscode/webview/locales/ko.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -38,6 +38,7 @@ export const ko: typeof en = {
"checkpoint.revertTitle": "이 지점으로 되돌리기",
"checkpoint.retryFromHere": "여기서 다시 시도",
"checkpoint.forkFromHere": "여기서 분기",
"scrollToBottom.ariaLabel": "맨 아래로 스크롤",

// Undo/Redo
"header.undo": "실행 취소",
Expand Down
5 changes: 3 additions & 2 deletions packages/platforms/vscode/webview/locales/pt-br.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions packages/platforms/vscode/webview/locales/ru.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -38,6 +38,7 @@ export const ru: typeof en = {
"checkpoint.revertTitle": "Вернуться к этой точке",
"checkpoint.retryFromHere": "Повторить отсюда",
"checkpoint.forkFromHere": "Ответвить отсюда",
"scrollToBottom.ariaLabel": "Прокрутить вниз",

// Undo/Redo
"header.undo": "Отменить",
Expand Down
Loading