From be167846eb0e6bdd12de588ae00bcade7d929c83 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:01:48 +0000 Subject: [PATCH 1/8] feat: Add /[lang]/sandbox page with REPL, code execution, file addition, and section chat --- app/(docs)/@chat/chat/[chatId]/chatArea.tsx | 24 +- .../@docs/[lang]/[pageId]/pageContent.tsx | 2 +- app/(docs)/@docs/[lang]/sandbox/page.tsx | 55 +++++ .../@docs/[lang]/sandbox/sandboxContent.tsx | 216 ++++++++++++++++++ app/api/chat/route.ts | 146 +++++++----- app/lib/chatHistory.ts | 12 + app/lib/docs.ts | 16 ++ app/sidebar.tsx | 14 ++ app/terminal/page.tsx | 69 +----- app/terminal/sampleConfig.ts | 72 ++++++ 10 files changed, 487 insertions(+), 139 deletions(-) create mode 100644 app/(docs)/@docs/[lang]/sandbox/page.tsx create mode 100644 app/(docs)/@docs/[lang]/sandbox/sandboxContent.tsx create mode 100644 app/terminal/sampleConfig.ts diff --git a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx index 63bef250..0de65b0e 100644 --- a/app/(docs)/@chat/chat/[chatId]/chatArea.tsx +++ b/app/(docs)/@chat/chat/[chatId]/chatArea.tsx @@ -127,20 +127,28 @@ export function ChatAreaContent(props: Props) {
diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx index 7beb8036..69bbaee7 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx @@ -167,7 +167,7 @@ export function PageContent(props: PageContentProps) { ); } -function ChatListForSection(props: { +export function ChatListForSection(props: { dynamicMdContent: DynamicMarkdownSection[]; sectionId: SectionId; chatHistories: ChatWithMessages[]; diff --git a/app/(docs)/@docs/[lang]/sandbox/page.tsx b/app/(docs)/@docs/[lang]/sandbox/page.tsx new file mode 100644 index 00000000..b213d36c --- /dev/null +++ b/app/(docs)/@docs/[lang]/sandbox/page.tsx @@ -0,0 +1,55 @@ +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { SandboxContent } from "./sandboxContent"; +import { getChatFromCache, initContext } from "@/lib/chatHistory"; +import { getPagesListForLang, getTermDefinitions, LangId, PageSlug } from "@/lib/docs"; +import { TermDefinitionProvider } from "@/markdown/term"; +import { DocsAutoRedirect } from "../[pageId]/autoRedirect"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ lang: LangId }>; +}): Promise { + const { lang } = await params; + const langEntry = await getPagesListForLang(lang); + if (!langEntry) notFound(); + + return { + title: `${langEntry.name} - Sandbox`, + description: `${langEntry.name} のインタラクティブなコード実行サンドボックスです。`, + }; +} + +export default async function Page({ + params, +}: { + params: Promise<{ lang: LangId }>; +}) { + const { lang } = await params; + + const langEntry = await getPagesListForLang(lang); + if (!langEntry) notFound(); + + const path = { lang, page: "sandbox" as PageSlug }; + const context = await initContext(); + const chatHistories = await getChatFromCache(path, context.userId); + const termDefinitions = await getTermDefinitions(lang); + + return ( + <> + + + + + + ); +} diff --git a/app/(docs)/@docs/[lang]/sandbox/sandboxContent.tsx b/app/(docs)/@docs/[lang]/sandbox/sandboxContent.tsx new file mode 100644 index 00000000..e4a34fd9 --- /dev/null +++ b/app/(docs)/@docs/[lang]/sandbox/sandboxContent.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useState, FormEvent, useEffect } from "react"; +import { Heading } from "@/markdown/heading"; +import { langConstants, RuntimeLang } from "@my-code/runtime/languages"; +import { ReplTerminal } from "@/terminal/repl"; +import { EditorComponent } from "@/terminal/editor"; +import { ExecFile } from "@/terminal/exec"; +import { sampleConfig } from "@/terminal/sampleConfig"; +import { + DynamicMarkdownSection, + LangId, + PagePath, + PageSlug, + SectionId, +} from "@/lib/docs"; +import { ChatWithMessages } from "@/lib/chatHistory"; +import { ChatForm } from "../[pageId]/chatForm"; +import { ChatListForSection } from "../[pageId]/pageContent"; +import { usePagesListForLang } from "@/pagesListContext"; +import { useEmbedContext } from "@/terminal/embedContext"; +import { useSidebarMdContext } from "@/sidebar"; + +interface SandboxContentProps { + langId: LangId; + path: PagePath; + chatHistories: ChatWithMessages[]; +} + +export function SandboxContent(props: SandboxContentProps) { + const { langId, path, chatHistories } = props; + const langEntry = usePagesListForLang(langId); + const { setSidebarMdContent } = useSidebarMdContext(); + const { writeFile } = useEmbedContext(); + + const runtimeLang = langId as RuntimeLang; + const config = sampleConfig[runtimeLang]; + + const [userFiles, setUserFiles] = useState([]); + const [newFilename, setNewFilename] = useState(""); + const [filenameError, setFilenameError] = useState(null); + const [isFormVisible, setIsFormVisible] = useState(false); + + const dummySection: DynamicMarkdownSection[] = [ + { + id: "sandbox" as SectionId, + level: 1, + title: "sandbox", + file: "sandbox.md", + rawContent: "", + md5: "", + replacedContent: "", + replacedRange: [], + inView: true, + }, + ]; + + useEffect(() => { + setSidebarMdContent(path, dummySection); + }, [path, setSidebarMdContent]); + + const handleAddFile = (e: FormEvent) => { + e.preventDefault(); + const name = newFilename.trim(); + if (!name) return; + + // 既存ファイルチェック + const defaultFiles = config?.editor ? Object.keys(config.editor) : []; + const readonlyFiles = config?.readonlyFiles ?? []; + if ( + defaultFiles.includes(name) || + readonlyFiles.includes(name) || + userFiles.includes(name) + ) { + setFilenameError("同名のファイルがすでに存在します。"); + return; + } + + setFilenameError(null); + setUserFiles((prev) => [...prev, name]); + writeFile({ [name]: "" }); + setNewFilename(""); + }; + + const handleRemoveFile = (filename: string) => { + setUserFiles((prev) => prev.filter((f) => f !== filename)); + }; + + return ( +
+
+ {langEntry?.name ?? langId} Sandbox +
+ +
+ {config?.repl && ( +
+ REPL + +
+ )} + + {config?.editor && ( +
+ サンプルコード + {Object.entries(config.editor).map(([filename, initContent]) => ( + + ))} +
+ )} + + {config?.exec && ( +
+ 実行 + +
+ )} + + {config?.readonlyFiles && config.readonlyFiles.length > 0 && ( +
+ 出力ファイル + {config.readonlyFiles.map((filename) => ( + + ))} +
+ )} + +
+ 追加ファイル +
+ { + setNewFilename(e.target.value); + setFilenameError(null); + }} + /> + +
+ {filenameError && ( +

{filenameError}

+ )} + + {userFiles.map((filename) => ( +
+
+ {filename} + +
+ +
+ ))} +
+ +
+ +
+
+ + {isFormVisible ? ( +
+ setIsFormVisible(false)} + /> +
+ ) : ( + + )} +
+ ); +} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 803b59cf..cef6d3d3 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -14,6 +14,7 @@ import { getPagesListForLang, introSectionId, PagePathSchema, + PageSlug, SectionId, } from "@/lib/docs"; import { @@ -129,40 +130,51 @@ export async function POST(request: NextRequest) { } } + const isSandbox = path.page === ("sandbox" as PageSlug); + const prompt: string[] = []; - prompt.push(`あなたは${langName}言語のチュートリアルの講師をしています。`); - prompt.push( - `以下の${langName}チュートリアルのドキュメントの内容を正確に理解し、ユーザーからの質問に対して、初心者にも分かりやすく、丁寧な解説を提供してください。` - ); - prompt.push(``); - const sectionTitlesInView = targetSectionContent - .filter((s) => s.inView) - .map((s) => s.title); - if (sectionTitlesInView.length > 0) { + if (isSandbox) { + prompt.push(`あなたは${langName}プログラミングの学習者をサポートする講師AIアシスタントです。`); prompt.push( - `ユーザーはドキュメント内の ${sectionTitlesInView.join(", ")} の付近のセクションを閲覧している際にこの質問を行っていると推測されます。` + `ユーザーからの質問に対して、初心者にも分かりやすく、丁寧な解説を提供してください。` ); + prompt.push(``); + } else { + prompt.push(`あなたは${langName}言語のチュートリアルの講師をしています。`); prompt.push( - `質問に答える際には、ユーザーが閲覧しているセクションの内容を特に考慮してください。` + `以下の${langName}チュートリアルのドキュメントの内容を正確に理解し、ユーザーからの質問に対して、初心者にも分かりやすく、丁寧な解説を提供してください。` ); - } - prompt.push(``); - prompt.push( - `質問への回答はユーザー向けのメッセージに加えて、ドキュメント自体を改訂するという形でも可能です。` - ); - prompt.push( - `質問内容とドキュメントの内容の関連性が深く、比較的長めの解説をしたい場合、またはドキュメントへの補足がしたい場合は、そちらの形式での回答を検討してください。` - ); - prompt.push(``); - prompt.push(`# ドキュメント`); - prompt.push(``); - for (const section of targetSectionContent) { - prompt.push(`[セクションid: ${section.id}]`); - prompt.push(section.replacedContent.trim()); + prompt.push(``); + const sectionTitlesInView = targetSectionContent + .filter((s) => s.inView) + .map((s) => s.title); + if (sectionTitlesInView.length > 0) { + prompt.push( + `ユーザーはドキュメント内の ${sectionTitlesInView.join(", ")} の付近のセクションを閲覧している際にこの質問を行っていると推測されます。` + ); + prompt.push( + `質問に答える際には、ユーザーが閲覧しているセクションの内容を特に考慮してください。` + ); + } + prompt.push(``); + prompt.push( + `質問への回答はユーザー向けのメッセージに加えて、ドキュメント自体を改訂するという形でも可能です。` + ); + prompt.push( + `質問内容とドキュメントの内容の関連性が深く、比較的長めの解説をしたい場合、またはドキュメントへの補足がしたい場合は、そちらの形式での回答を検討してください。` + ); + prompt.push(``); + prompt.push(`# ドキュメント`); + prompt.push(``); + for (const section of targetSectionContent) { + prompt.push(`[セクションid: ${section.id}]`); + prompt.push(section.replacedContent.trim()); + prompt.push(``); + } prompt.push(``); } - prompt.push(``); + if (Object.keys(replOutputs).length > 0) { prompt.push( `# ターミナルのログ(ユーザーが入力したコマンドとその実行結果)` @@ -224,15 +236,19 @@ export async function POST(request: NextRequest) { prompt.push("# 指示"); prompt.push(""); - prompt.push( - `- 1行目に、ユーザーの質問ともっとも関連性の高いドキュメント内のセクションのidを回答してください。` - ); - prompt.push( - " - idのみを出力してください。 セクションid: や括弧や引用符などは不要です。" - ); - prompt.push( - " - ユーザーの質問がドキュメントのどのセクションとも直接的に関連しない場合は null と出力してください。" - ); + if (isSandbox) { + prompt.push(`- 1行目に sandbox とのみ出力してください。`); + } else { + prompt.push( + `- 1行目に、ユーザーの質問ともっとも関連性の高いドキュメント内のセクションのidを回答してください。` + ); + prompt.push( + " - idのみを出力してください。 セクションid: や括弧や引用符などは不要です。" + ); + prompt.push( + " - ユーザーの質問がドキュメントのどのセクションとも直接的に関連しない場合は null と出力してください。" + ); + } prompt.push( "- 2行目に、この質問と回答を後から参照するためのわかりやすいタイトルをつけて記述してください。" ); @@ -240,7 +256,7 @@ export async function POST(request: NextRequest) { " - 太字やコードブロックなどのMarkdownの記法は使わずテキストのみで出力してください。" ); prompt.push( - "- 3行目以降に、ドキュメントの内容に基づいて、ユーザーに伝える回答をMarkdown形式で記述してください。" + "- 3行目以降に、ユーザーに伝える回答をMarkdown形式で記述してください。" ); prompt.push( " - ユーザーが入力したターミナルのコマンドやファイルの内容、実行結果を参考にして回答してください。" @@ -250,29 +266,32 @@ export async function POST(request: NextRequest) { " - 回答内でコードブロックを使用する際は ```言語名 としてください。" + "ドキュメント内では ```言語名-repl や ```言語名:ファイル名 、 ```言語名-exec:ファイル名 などの特殊なコードブロックが登場しますが、ユーザーへの回答ではこれらの記法は使用しないでください。" ); - prompt.push("- ドキュメントの一部を改訂したい場合はその差分を"); - prompt.push("<<<<<<< SEARCH"); - prompt.push("修正したい元の文章の塊(一字一句違わずに)"); - prompt.push("======="); - prompt.push("修正後の新しい文章の塊"); - prompt.push(">>>>>>> REPLACE"); - prompt.push("の形式で出力してください。"); - prompt.push( - " - 複数箇所改訂したい場合は上の形式の出力を複数回繰り返してください。" - ); - prompt.push( - " - ドキュメントにテキストを追加したい場合は追加したい箇所の前後のテキストを含めて出力してください。" - ); - prompt.push( - " - セクションid、セクション見出しを編集、追加、削除することはできません。" - ); - prompt.push( - " - ドキュメント内の特殊なコードブロック(```言語名-repl , ```言語名:ファイル名 , ```言語名-exec:ファイル名 )は編集、追加、削除することはできません。それ以外の文章のみを編集してください。" + - "ただし通常のコードブロック(```言語名 )の追加は可能です。" - ); - prompt.push( - " - 改訂後のドキュメントと同じ内容はユーザーに伝える回答としては省略できます。(「修正後のドキュメントを参照してください。」など)" - ); + + if (!isSandbox) { + prompt.push("- ドキュメントの一部を改訂したい場合はその差分を"); + prompt.push("<<<<<<< SEARCH"); + prompt.push("修正したい元の文章の塊(一字一句違わずに)"); + prompt.push("======="); + prompt.push("修正後の新しい文章の塊"); + prompt.push(">>>>>>> REPLACE"); + prompt.push("の形式で出力してください。"); + prompt.push( + " - 複数箇所改訂したい場合は上の形式の出力を複数回繰り返してください。" + ); + prompt.push( + " - ドキュメントにテキストを追加したい場合は追加したい箇所の前後のテキストを含めて出力してください。" + ); + prompt.push( + " - セクションid、セクション見出しを編集、追加、削除することはできません。" + ); + prompt.push( + " - ドキュメント内の特殊なコードブロック(```言語名-repl , ```言語名:ファイル名 , ```言語名-exec:ファイル名 )は編集、追加、削除することはできません。それ以外の文章のみを編集してください。" + + "ただし通常のコードブロック(```言語名 )の追加は可能です。" + ); + prompt.push( + " - 改訂後のドキュメントと同じ内容はユーザーに伝える回答としては省略できます。(「修正後のドキュメントを参照してください。」など)" + ); + } console.log(prompt); @@ -303,12 +322,15 @@ export async function POST(request: NextRequest) { const headerMatch = fullText.match(/^([^\n]+?)\n+([^\n]+?)\n+/); if (headerMatch) { headerParsed = true; - let targetSectionId = headerMatch[1].trim() as SectionId; + let targetSectionId = isSandbox + ? ("sandbox" as SectionId) + : (headerMatch[1].trim() as SectionId); const title = headerMatch[2].trim(); if ( - !targetSectionId || - !targetSectionContent.some((s) => s.id === targetSectionId) + !isSandbox && + (!targetSectionId || + !targetSectionContent.some((s) => s.id === targetSectionId)) ) { targetSectionId = introSectionId(targetPath); } diff --git a/app/lib/chatHistory.ts b/app/lib/chatHistory.ts index daca57b9..95f9887b 100644 --- a/app/lib/chatHistory.ts +++ b/app/lib/chatHistory.ts @@ -140,6 +140,18 @@ export async function addChat( if (!userId) { throw new Error("Not authenticated"); } + + await drizzle + .insert(section) + .values({ + sectionId, + pagePath: `${path.lang}/${path.page}`, + }) + .onConflictDoUpdate({ + target: section.sectionId, + set: { pagePath: `${path.lang}/${path.page}` }, + }); + const [newChat] = await drizzle .insert(chat) .values({ diff --git a/app/lib/docs.ts b/app/lib/docs.ts index 4b8155fd..de1ec332 100644 --- a/app/lib/docs.ts +++ b/app/lib/docs.ts @@ -296,6 +296,22 @@ export async function getMarkdownSections( lang: LangId, page: PageSlug ): Promise { + if (page === ("sandbox" as PageSlug)) { + if (!(await getLanguageIds()).includes(lang)) { + notFound(); + } + return [ + { + file: "sandbox.md", + id: "sandbox" as SectionId, + level: 1, + title: "sandbox", + rawContent: "", + md5: "", + }, + ]; + } + if ( /*!(await getLanguageIds()).includes(lang) || // getPagesListForLangのなかでチェック */ !(await getPagesListForLang(lang)).pages.some((p) => p.slug === page) diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 1615ee93..1d98b8b1 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -225,6 +225,20 @@ export function Sidebar() { )} ))} +
  • + + + Sandbox + +
  • diff --git a/app/terminal/page.tsx b/app/terminal/page.tsx index ba9a32ec..0431f410 100644 --- a/app/terminal/page.tsx +++ b/app/terminal/page.tsx @@ -17,15 +17,7 @@ import { fileExecutionTests } from "@my-code/runtime/tests/fileExecution"; import { useRuntimeAll } from "@my-code/runtime/context"; import { captureException } from "@sentry/nextjs"; -import main_py from "./samples/main.py?raw"; -import main_rb from "./samples/main.rb?raw"; -import main_js from "./samples/main.js?raw"; -import main2_ts from "./samples/main2.ts?raw"; -import main_cpp from "./samples/main.cpp?raw"; -import sub_h from "./samples/sub.h?raw"; -import sub_cpp from "./samples/sub.cpp?raw"; -import main2_rs from "./samples/main2.rs?raw"; -import sub_rs from "./samples/sub.rs?raw"; +import { sampleConfig, SampleConfig } from "./sampleConfig"; import { DaisyInfoIcon } from "@/daisyAlertIcon"; export default function RuntimeTestPage() { @@ -69,65 +61,6 @@ export default function RuntimeTestPage() { ); } -interface SampleConfig { - repl: boolean; - replInitContent?: string; // ReplOutput[] ではない。stringのパースはruntimeが行う - editor: Record | false; - exec: string[] | false; - readonlyFiles?: string[]; -} -const sampleConfig: Record = { - python: { - repl: true, - replInitContent: '>>> print("Hello, World!")\nHello, World!', - editor: { - "main.py": main_py, - }, - exec: ["main.py"], - }, - ruby: { - repl: true, - replInitContent: 'irb(main):001:0> puts "Hello, World!"\nHello, World!', - editor: { - "main.rb": main_rb, - }, - exec: ["main.rb"], - }, - javascript: { - repl: true, - replInitContent: '> console.log("Hello, World!");\nHello, World!', - editor: { - "main.js": main_js, - }, - exec: ["main.js"], - }, - typescript: { - repl: false, - editor: { - // main.tsにすると出力ファイルがjavascriptのサンプルと被る - "main2.ts": main2_ts, - }, - exec: ["main2.ts"], - readonlyFiles: ["main2.js"], - }, - cpp: { - repl: false, - editor: { - "main.cpp": main_cpp, - "sub.h": sub_h, - "sub.cpp": sub_cpp, - }, - exec: ["main.cpp", "sub.cpp"], - }, - rust: { - repl: false, - editor: { - "main2.rs": main2_rs, - "sub.rs": sub_rs, - }, - exec: ["main2.rs"], - }, -}; function RuntimeSample({ lang, config, diff --git a/app/terminal/sampleConfig.ts b/app/terminal/sampleConfig.ts new file mode 100644 index 00000000..0106003d --- /dev/null +++ b/app/terminal/sampleConfig.ts @@ -0,0 +1,72 @@ +import { RuntimeLang } from "@my-code/runtime/languages"; + +import main_py from "./samples/main.py?raw"; +import main_rb from "./samples/main.rb?raw"; +import main_js from "./samples/main.js?raw"; +import main2_ts from "./samples/main2.ts?raw"; +import main_cpp from "./samples/main.cpp?raw"; +import sub_h from "./samples/sub.h?raw"; +import sub_cpp from "./samples/sub.cpp?raw"; +import main2_rs from "./samples/main2.rs?raw"; +import sub_rs from "./samples/sub.rs?raw"; + +export interface SampleConfig { + repl: boolean; + replInitContent?: string; // ReplOutput[] ではない。stringのパースはruntimeが行う + editor: Record | false; + exec: string[] | false; + readonlyFiles?: string[]; +} + +export const sampleConfig: Record = { + python: { + repl: true, + replInitContent: '>>> print("Hello, World!")\nHello, World!', + editor: { + "main.py": main_py, + }, + exec: ["main.py"], + }, + ruby: { + repl: true, + replInitContent: 'irb(main):001:0> puts "Hello, World!"\nHello, World!', + editor: { + "main.rb": main_rb, + }, + exec: ["main.rb"], + }, + javascript: { + repl: true, + replInitContent: '> console.log("Hello, World!");\nHello, World!', + editor: { + "main.js": main_js, + }, + exec: ["main.js"], + }, + typescript: { + repl: false, + editor: { + // main.tsにすると出力ファイルがjavascriptのサンプルと被る + "main2.ts": main2_ts, + }, + exec: ["main2.ts"], + readonlyFiles: ["main2.js"], + }, + cpp: { + repl: false, + editor: { + "main.cpp": main_cpp, + "sub.h": sub_h, + "sub.cpp": sub_cpp, + }, + exec: ["main.cpp", "sub.cpp"], + }, + rust: { + repl: false, + editor: { + "main2.rs": main2_rs, + "sub.rs": sub_rs, + }, + exec: ["main2.rs"], + }, +}; From 6a396d57be4d675d16d2760ee4a0705e773a6bf9 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:43:05 +0000 Subject: [PATCH 2/8] feat(terminal): add supportsMultiFile config and delete button to EditorComponent --- app/terminal/editor.tsx | 48 ++++++++++++++++++++++++++++++++++++ app/terminal/sampleConfig.ts | 7 ++++++ 2 files changed, 55 insertions(+) diff --git a/app/terminal/editor.tsx b/app/terminal/editor.tsx index 14d2547b..b2f910cd 100644 --- a/app/terminal/editor.tsx +++ b/app/terminal/editor.tsx @@ -37,6 +37,7 @@ interface EditorProps { filename: string; initContent: string; readonly?: boolean; + onDelete?: () => void; } export function EditorComponent(props: EditorProps) { const theme = useChangeTheme(); @@ -129,6 +130,53 @@ export function EditorComponent(props: EditorProps) { 元の内容に戻す
    + {props.onDelete && ( + + )}
    {fontSize !== undefined && initAce ? ( diff --git a/app/terminal/sampleConfig.ts b/app/terminal/sampleConfig.ts index 0106003d..0fa23cc7 100644 --- a/app/terminal/sampleConfig.ts +++ b/app/terminal/sampleConfig.ts @@ -16,6 +16,7 @@ export interface SampleConfig { editor: Record | false; exec: string[] | false; readonlyFiles?: string[]; + supportsMultiFile?: boolean; } export const sampleConfig: Record = { @@ -26,6 +27,7 @@ export const sampleConfig: Record = { "main.py": main_py, }, exec: ["main.py"], + supportsMultiFile: true, }, ruby: { repl: true, @@ -34,6 +36,7 @@ export const sampleConfig: Record = { "main.rb": main_rb, }, exec: ["main.rb"], + supportsMultiFile: true, }, javascript: { repl: true, @@ -42,6 +45,7 @@ export const sampleConfig: Record = { "main.js": main_js, }, exec: ["main.js"], + supportsMultiFile: false, }, typescript: { repl: false, @@ -51,6 +55,7 @@ export const sampleConfig: Record = { }, exec: ["main2.ts"], readonlyFiles: ["main2.js"], + supportsMultiFile: false, }, cpp: { repl: false, @@ -60,6 +65,7 @@ export const sampleConfig: Record = { "sub.cpp": sub_cpp, }, exec: ["main.cpp", "sub.cpp"], + supportsMultiFile: true, }, rust: { repl: false, @@ -68,5 +74,6 @@ export const sampleConfig: Record = { "sub.rs": sub_rs, }, exec: ["main2.rs"], + supportsMultiFile: true, }, }; From 05449e8d0d2e36d9d036ea33f4aefbc919030d96 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:43:28 +0000 Subject: [PATCH 3/8] feat(sandbox): reorder sections and add TOC scroll tracking --- .../@docs/[lang]/sandbox/sandboxContent.tsx | 198 ++++++++++++------ 1 file changed, 131 insertions(+), 67 deletions(-) diff --git a/app/(docs)/@docs/[lang]/sandbox/sandboxContent.tsx b/app/(docs)/@docs/[lang]/sandbox/sandboxContent.tsx index e4a34fd9..7ceebc68 100644 --- a/app/(docs)/@docs/[lang]/sandbox/sandboxContent.tsx +++ b/app/(docs)/@docs/[lang]/sandbox/sandboxContent.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, FormEvent, useEffect } from "react"; +import { useState, FormEvent, useEffect, useMemo, useRef } from "react"; import { Heading } from "@/markdown/heading"; import { langConstants, RuntimeLang } from "@my-code/runtime/languages"; import { ReplTerminal } from "@/terminal/repl"; @@ -11,7 +11,6 @@ import { DynamicMarkdownSection, LangId, PagePath, - PageSlug, SectionId, } from "@/lib/docs"; import { ChatWithMessages } from "@/lib/chatHistory"; @@ -41,23 +40,71 @@ export function SandboxContent(props: SandboxContentProps) { const [filenameError, setFilenameError] = useState(null); const [isFormVisible, setIsFormVisible] = useState(false); - const dummySection: DynamicMarkdownSection[] = [ - { - id: "sandbox" as SectionId, - level: 1, - title: "sandbox", + // サイドバーの目次用セクション定義 + const baseSections = useMemo(() => { + const list: Array<{ id: SectionId; title: string; level: number }> = []; + if (config?.repl) { + list.push({ id: "sandbox-repl" as SectionId, title: "REPL", level: 2 }); + } + if (config?.editor || userFiles.length > 0) { + list.push({ id: "sandbox-editor" as SectionId, title: "コード", level: 2 }); + } + if (config?.exec) { + list.push({ id: "sandbox-exec" as SectionId, title: "実行", level: 2 }); + } + if (config?.readonlyFiles && config.readonlyFiles.length > 0) { + list.push({ + id: "sandbox-readonly" as SectionId, + title: "出力ファイル", + level: 2, + }); + } + return list; + }, [config, userFiles.length]); + + const [sectionInView, setSectionInView] = useState([]); + const sectionRefs = useRef>(new Map()); + + useEffect(() => { + const handleScroll = () => { + setSectionInView( + baseSections.map((sec) => { + const el = sectionRefs.current.get(sec.id); + if (el) { + const rect = el.getBoundingClientRect(); + return ( + rect.top < window.innerHeight * 0.9 && + rect.bottom >= window.innerHeight * 0.1 + ); + } + return false; + }) + ); + }; + window.addEventListener("scroll", handleScroll); + handleScroll(); + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, [baseSections]); + + const dynamicSections: DynamicMarkdownSection[] = useMemo(() => { + return baseSections.map((sec, i) => ({ + id: sec.id, + title: sec.title, + level: sec.level, file: "sandbox.md", rawContent: "", md5: "", replacedContent: "", replacedRange: [], - inView: true, - }, - ]; + inView: sectionInView[i] ?? false, + })); + }, [baseSections, sectionInView]); useEffect(() => { - setSidebarMdContent(path, dummySection); - }, [path, setSidebarMdContent]); + setSidebarMdContent(path, dynamicSections); + }, [dynamicSections, path, setSidebarMdContent]); const handleAddFile = (e: FormEvent) => { e.preventDefault(); @@ -93,44 +140,102 @@ export function SandboxContent(props: SandboxContentProps) {
    + {/* 1. REPL */} {config?.repl && ( -
    +
    { + sectionRefs.current.set("sandbox-repl", el); + }} + > REPL -
    + )} - {config?.editor && ( -
    - サンプルコード - {Object.entries(config.editor).map(([filename, initContent]) => ( + {/* 2. エディター (既存ファイル + 追加ファイル + 追加ボタン) */} + {(config?.editor || userFiles.length > 0 || config?.supportsMultiFile) && ( +
    { + sectionRefs.current.set("sandbox-editor", el); + }} + > + コード + {config?.editor && + Object.entries(config.editor).map(([filename, initContent]) => ( + + ))} + + {userFiles.map((filename) => ( handleRemoveFile(filename)} /> ))} -
    + + {config?.supportsMultiFile && ( +
    +
    + { + setNewFilename(e.target.value); + setFilenameError(null); + }} + /> + +
    + {filenameError && ( +

    {filenameError}

    + )} +
    + )} + )} + {/* 3. 実行 */} {config?.exec && ( -
    +
    { + sectionRefs.current.set("sandbox-exec", el); + }} + > 実行 -
    + )} + {/* 4. 出力ファイル */} {config?.readonlyFiles && config.readonlyFiles.length > 0 && ( -
    +
    { + sectionRefs.current.set("sandbox-readonly", el); + }} + > 出力ファイル {config.readonlyFiles.map((filename) => ( ))} -
    + )} -
    - 追加ファイル -
    - { - setNewFilename(e.target.value); - setFilenameError(null); - }} - /> - -
    - {filenameError && ( -

    {filenameError}

    - )} - - {userFiles.map((filename) => ( -
    -
    - {filename} - -
    - -
    - ))} -
    -
    @@ -199,7 +263,7 @@ export function SandboxContent(props: SandboxContentProps) { setIsFormVisible(false)} />
    From be051ff5d07cb2b7a52d9b3aee1ae6e4cf425a46 Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:43:44 +0000 Subject: [PATCH 4/8] feat(sidebar): move Sandbox to top of language list and render its TOC --- app/sidebar.tsx | 59 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/app/sidebar.tsx b/app/sidebar.tsx index 1d98b8b1..09e23061 100644 --- a/app/sidebar.tsx +++ b/app/sidebar.tsx @@ -90,7 +90,10 @@ export function Sidebar() { // 現在表示中のセクション(最初にinViewがtrueのもの)を見つける const currentSectionId = sidebarMdContent.find( - (section, i) => i >= 1 && section.inView + (section, i) => + Boolean(section.title) && + (currentPageId === ("sandbox" as PageSlug) || i >= 1) && + section.inView )?.id; // 目次の開閉状態 @@ -181,6 +184,46 @@ export function Sidebar() { {group.name} From a47b975c402c52bf8787c181aaaad72f559005da Mon Sep 17 00:00:00 2001 From: na-trium-144 <100704180+na-trium-144@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:17:23 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=E8=A6=8B=E3=81=9F=E7=9B=AE=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../@docs/[lang]/[pageId]/pageContent.tsx | 27 ++++++---- .../@docs/[lang]/sandbox/sandboxContent.tsx | 51 ++++++++++++------- app/api/chat/route.ts | 47 +++++++++++------ 3 files changed, 84 insertions(+), 41 deletions(-) diff --git a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx index 69bbaee7..82dd4160 100644 --- a/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx +++ b/app/(docs)/@docs/[lang]/[pageId]/pageContent.tsx @@ -171,6 +171,7 @@ export function ChatListForSection(props: { dynamicMdContent: DynamicMarkdownSection[]; sectionId: SectionId; chatHistories: ChatWithMessages[]; + fullWidth?: boolean; }) { const { dynamicMdContent, sectionId, chatHistories } = props; const filteredChatHistories = chatHistories.filter( @@ -195,10 +196,14 @@ export function ChatListForSection(props: { */}