diff --git a/src/components/WelcomePopup.tsx b/src/components/WelcomePopup.tsx index be22870b..5684a955 100644 --- a/src/components/WelcomePopup.tsx +++ b/src/components/WelcomePopup.tsx @@ -65,7 +65,10 @@ const TERMINALS = [ }, ] as const; -type TutorialStep = 0 | 1 | 2 | 3 | 4 | 5; +type TutorialStep = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; + +// Total interactive + info steps (excluding step 0 welcome page) +const TOTAL_STEPS = 7; // Terminal cell center offsets from grid center. // Grid: 2 cols × 2 rows, cell 120×80, gap 8px → total 248×168. @@ -81,6 +84,29 @@ function replaceToken(template: string, token: string, value: string): string { return template.replace(token, value); } +function StepDots({ current, total }: { current: number; total: number }) { + return ( +
+ {Array.from({ length: total }, (_, i) => ( +
+ ))} +
+ ); +} + function MiniCanvas({ focusedIndex, step, @@ -236,6 +262,22 @@ function MiniCanvas({ ); } +/** Kbd-style shortcut badge */ +function Kbd({ children }: { children: string }) { + return ( + + {children} + + ); +} + export function WelcomePopup({ onClose }: Props) { const shortcuts = useShortcutStore((s) => s.shortcuts); const backdropRef = useRef(null); @@ -261,6 +303,17 @@ export function WelcomePopup({ onClose }: Props) { [step], ); + const goBack = useCallback(() => { + // Only allow going back on non-interactive info steps (5, 6, 7) + if (step === 5) { + setStep(4); + } else if (step === 6) { + setStep(5); + } else if (step === 7) { + setStep(6); + } + }, [step]); + useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") { @@ -338,14 +391,43 @@ export function WelcomePopup({ onClose }: Props) { return; } - if (step === 5 && e.key === "Enter") { - onClose(); + // Info steps: Enter advances, Backspace goes back + if (step === 5) { + if (e.key === "Backspace") { + goBack(); + return; + } + if (e.key === "Enter") { + setStep(6); + return; + } + } + + if (step === 6) { + if (e.key === "Backspace") { + goBack(); + return; + } + if (e.key === "Enter") { + setStep(7); + return; + } + } + + if (step === 7) { + if (e.key === "Backspace") { + goBack(); + return; + } + if (e.key === "Enter") { + onClose(); + } } }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [step, hasDoubleClicked, focusToggleCount, switchCount, hasInteractedZoom, shortcuts, onClose]); + }, [step, hasDoubleClicked, focusToggleCount, switchCount, hasInteractedZoom, shortcuts, onClose, goBack]); const shortcutItems = [ { key: shortcuts.addProject, en: en.shortcut_add_project, zh: zh.shortcut_add_project }, @@ -355,7 +437,7 @@ export function WelcomePopup({ onClose }: Props) { { key: shortcuts.clearFocus, en: en.shortcut_clear_focus, zh: zh.shortcut_clear_focus }, ]; - const steps = [ + const welcomeSteps = [ { en: en.welcome_step_1, zh: zh.welcome_step_1 }, { en: en.welcome_step_2, zh: zh.welcome_step_2 }, { en: en.welcome_step_3, zh: zh.welcome_step_3 }, @@ -365,6 +447,11 @@ export function WelcomePopup({ onClose }: Props) { const fmtNext = formatShortcut(shortcuts.nextTerminal, isMac); const fmtPrev = formatShortcut(shortcuts.prevTerminal, isMac); const fmtAddProject = formatShortcut(shortcuts.addProject, isMac); + const fmtNewTerminal = formatShortcut(shortcuts.newTerminal, isMac); + const fmtToggleSidebar = formatShortcut(shortcuts.toggleSidebar, isMac); + const fmtSave = formatShortcut(shortcuts.saveWorkspace, isMac); + const fmtCloseFocused = formatShortcut(shortcuts.closeFocused, isMac); + const fmtToggleStar = formatShortcut(shortcuts.toggleStarFocused, isMac); function getPrompt(): { en: string; zh: string } | null { switch (step) { @@ -408,11 +495,6 @@ export function WelcomePopup({ onClose }: Props) { return { en: en.onboarding_zoom_continue, zh: zh.onboarding_zoom_continue }; } return { en: en.onboarding_zoom_prompt, zh: zh.onboarding_zoom_prompt }; - case 5: - return { - en: replaceToken(en.onboarding_complete, "{shortcut}", fmtAddProject), - zh: replaceToken(zh.onboarding_complete, "{shortcut}", fmtAddProject), - }; default: return null; } @@ -420,6 +502,11 @@ export function WelcomePopup({ onClose }: Props) { const prompt = getPrompt(); + // Whether the current step shows the mini-canvas (interactive steps 1–4) + const isInteractiveStep = step >= 1 && step <= 4; + // Whether the current step is an info page (steps 5–7) + const isInfoStep = step >= 5 && step <= 7; + return (
- {steps.map((stepItem, index) => ( + {welcomeSteps.map((stepItem, index) => (
{index + 1}.{" "} @@ -525,7 +612,7 @@ export function WelcomePopup({ onClose }: Props) {
- ) : ( + ) : isInteractiveStep ? ( <>
)} - {step === 5 && ( -
+
+ +
+
+ + + + ) : isInfoStep && step === 5 ? ( + <> + {/* Step 5: Composer & Tools */} +
+
+ +
+
+ +
+ + {/* Mini composer illustration */} +
+
+
+
+ node +
+
+ fix the CORS error in server.js +
+
+ Send +
+
+
+ Enter sends · Shift+Enter newline · paste images for agents +
+
+ +
+
- )} - {step >= 1 && step <= 4 && ( -
- +
+
- )} +
+ +
+
+
+ + {/* Navigation buttons */} +
+ +
+ +
+ +
+ + + + ) : isInfoStep && step === 6 ? ( + <> + {/* Step 6: Keyboard shortcuts reference */} +
+
+ +
+
+ +
+ +
+ {[ + { kbd: fmtAddProject, en: en.shortcut_add_project, zh: zh.shortcut_add_project }, + { kbd: fmtNewTerminal, en: en.shortcut_new_terminal, zh: zh.shortcut_new_terminal }, + { kbd: fmtClearFocus, en: en.shortcut_clear_focus, zh: zh.shortcut_clear_focus }, + { kbd: fmtNext, en: en.shortcut_next_terminal, zh: zh.shortcut_next_terminal }, + { kbd: fmtPrev, en: en.shortcut_prev_terminal, zh: zh.shortcut_prev_terminal }, + { kbd: fmtToggleSidebar, en: en.shortcut_toggle_sidebar, zh: zh.shortcut_toggle_sidebar }, + { kbd: fmtCloseFocused, en: en.shortcut_close_focused, zh: zh.shortcut_close_focused }, + { kbd: fmtToggleStar, en: en.shortcut_toggle_star_focused, zh: zh.shortcut_toggle_star_focused }, + { kbd: fmtSave, en: en.shortcut_save_workspace, zh: zh.shortcut_save_workspace }, + ].map((item) => ( +
+ {item.kbd} + + + +
+ ))} +
+ + {/* Navigation buttons */} +
+ +
+ +
+ +
+ + + + ) : step === 7 ? ( + <> + {/* Step 7: What makes TermCanvas different + completion */} +
+
+ +
+ +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ +
+ +
+
+ + {/* Navigation buttons */} +
+ +
+ +
+ +
+ + - )} + ) : null}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 8fdab4d4..ab4a68a0 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -313,9 +313,30 @@ export const en = { onboarding_switch_continue: "Press Enter to continue", onboarding_zoom_prompt: "Scroll to zoom, drag to pan", onboarding_zoom_continue: "Press Enter to continue", - onboarding_complete: "Tutorial complete! Close this window, then press {shortcut} to add your first project.", + onboarding_complete: "You're all set! Press {shortcut} to add your first project.", onboarding_complete_dismiss: "Press Enter or Escape to close.", onboarding_skip: "Escape to skip", + onboarding_back: "Back", + onboarding_next: "Next", + onboarding_done: "Get Started", + + // Onboarding step 5: Composer + onboarding_composer_title: "Composer Bar", + onboarding_composer_desc: "Send text and images to any focused terminal without switching windows.", + onboarding_composer_tip_1: "Focus a terminal, then type in the composer to send commands", + onboarding_composer_tip_2: "Paste screenshots directly — agent terminals accept images", + onboarding_composer_tip_3: "Drag and drop files onto the composer to attach them", + + // Onboarding step 6: Shortcuts + onboarding_shortcuts_title: "Keyboard Shortcuts", + onboarding_shortcuts_desc: "Everything is keyboard-driven. Here are the essentials:", + + // Onboarding step 7: What makes TermCanvas different + onboarding_unique_title: "Why TermCanvas?", + onboarding_unique_1: "Infinite canvas — arrange terminals spatially, not in tabs", + onboarding_unique_2: "AI agents live alongside your terminals as first-class citizens", + onboarding_unique_3: "Git worktree-aware — each branch gets its own workspace", + onboarding_unique_4: "Save & restore full workspaces, including terminal state", } as const; export type TranslationKey = keyof typeof en; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 7062e751..2ae2095c 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -311,7 +311,28 @@ export const zh = { onboarding_switch_continue: "按 Enter 继续", onboarding_zoom_prompt: "滚轮缩放,拖拽平移", onboarding_zoom_continue: "按 Enter 继续", - onboarding_complete: "教程完成!关闭此窗口后,按 {shortcut} 添加你的第一个项目。", + onboarding_complete: "准备就绪!按 {shortcut} 添加你的第一个项目。", onboarding_complete_dismiss: "按 Enter 或 Escape 关闭。", onboarding_skip: "Escape 跳过", + onboarding_back: "上一步", + onboarding_next: "下一步", + onboarding_done: "开始使用", + + // Onboarding step 5: Composer + onboarding_composer_title: "Composer 输入栏", + onboarding_composer_desc: "无需切换窗口,直接向聚焦的终端发送文本和图片。", + onboarding_composer_tip_1: "聚焦终端后,在 Composer 中输入即可发送命令", + onboarding_composer_tip_2: "直接粘贴截图 — Agent 终端支持图片输入", + onboarding_composer_tip_3: "拖拽文件到 Composer 即可附加文件", + + // Onboarding step 6: Shortcuts + onboarding_shortcuts_title: "键盘快捷键", + onboarding_shortcuts_desc: "一切皆可键盘操作,以下是最常用的快捷键:", + + // Onboarding step 7: What makes TermCanvas different + onboarding_unique_title: "为什么选择 TermCanvas?", + onboarding_unique_1: "无限画布 — 空间化排列终端,告别标签页切换", + onboarding_unique_2: "AI Agent 作为一等公民,与终端并肩工作", + onboarding_unique_3: "Git 工作树感知 — 每个分支拥有独立工作区", + onboarding_unique_4: "保存和恢复完整工作区,包括终端状态", } as const; diff --git a/tests/onboarding-tutorial.test.ts b/tests/onboarding-tutorial.test.ts index 856bf2b3..2d2eb4ee 100644 --- a/tests/onboarding-tutorial.test.ts +++ b/tests/onboarding-tutorial.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -type TutorialStep = 0 | 1 | 2 | 3 | 4; +type TutorialStep = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; interface TutorialState { step: TutorialStep; @@ -34,13 +34,34 @@ function handleEnter(state: TutorialState): TutorialState { return { ...state, step: 4 }; } + // Info steps: Enter advances linearly if (state.step === 4) { + return { ...state, step: 5 }; + } + + if (state.step === 5) { + return { ...state, step: 6 }; + } + + if (state.step === 6) { + return { ...state, step: 7 }; + } + + // Step 7 = final, Enter closes (modeled as staying at 7) + if (state.step === 7) { return state; } return state; } +function handleBack(state: TutorialState): TutorialState { + if (state.step === 5) return { ...state, step: 4 }; + if (state.step === 6) return { ...state, step: 5 }; + if (state.step === 7) return { ...state, step: 6 }; + return state; +} + function handleFocus(state: TutorialState): TutorialState { if (state.step !== 1) { return state; @@ -152,10 +173,58 @@ test("step 3 -> zoom interaction enables Enter to advance to step 4", () => { assert.equal(completed.step, 4); }); +test("steps 4 -> 5 -> 6 -> 7 advance linearly with Enter", () => { + let state: TutorialState = { + step: 4, + focusedIndex: 1, + switchCount: 3, + hasInteractedZoom: true, + }; + + state = handleEnter(state); + assert.equal(state.step, 5); + + state = handleEnter(state); + assert.equal(state.step, 6); + + state = handleEnter(state); + assert.equal(state.step, 7); + + // Step 7 is final — Enter stays + state = handleEnter(state); + assert.equal(state.step, 7); +}); + +test("back navigation works on info steps 5, 6, 7", () => { + const step5: TutorialState = { + step: 5, + focusedIndex: 1, + switchCount: 3, + hasInteractedZoom: true, + }; + + assert.equal(handleBack(step5).step, 4); + assert.equal(handleBack({ ...step5, step: 6 }).step, 5); + assert.equal(handleBack({ ...step5, step: 7 }).step, 6); +}); + +test("back navigation is ignored on non-info steps", () => { + const step0 = initialState(); + const step2: TutorialState = { + step: 2, + focusedIndex: 1, + switchCount: 0, + hasInteractedZoom: false, + }; + + assert.deepEqual(handleBack(step0), step0); + assert.deepEqual(handleBack(step2), step2); +}); + test("focus/switch/zoom actions are ignored on wrong steps", () => { const step0 = initialState(); - const step4: TutorialState = { - step: 4, + const step7: TutorialState = { + step: 7, focusedIndex: 1, switchCount: 3, hasInteractedZoom: true, @@ -166,8 +235,8 @@ test("focus/switch/zoom actions are ignored on wrong steps", () => { assert.deepEqual(handlePrevTerminal(step0), step0); assert.deepEqual(handleZoomOrPan(step0), step0); - assert.deepEqual(handleFocus(step4), step4); - assert.deepEqual(handleNextTerminal(step4), step4); - assert.deepEqual(handlePrevTerminal(step4), step4); - assert.deepEqual(handleZoomOrPan(step4), step4); + assert.deepEqual(handleFocus(step7), step7); + assert.deepEqual(handleNextTerminal(step7), step7); + assert.deepEqual(handlePrevTerminal(step7), step7); + assert.deepEqual(handleZoomOrPan(step7), step7); });