feat: add global AI guide to LMS launcher

This commit is contained in:
2026-07-15 14:08:30 +09:00
parent 94300fc7ef
commit f38252d21e
17 changed files with 605 additions and 169 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

+73 -8
View File
@@ -19,6 +19,7 @@ import MaterialsModal from './features/curriculum/MaterialsModal'
import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi'
import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
import { getContextHelpSteps } from './features/help/contextHelp'
import AiGuideView from './features/AiGuideView'
import type { PlatformFilter } from './features/platforms/PlatformFilterTabs'
import PlatformSelectModal from './features/platforms/PlatformSelectModal'
import PlatformSelectionPanel from './features/platforms/PlatformSelectionPanel'
@@ -653,7 +654,7 @@ function LocalPcWorkspaceMockup({
const textMid = dark ? 'text-white/70' : 'text-[#4f596b]'
const textDim = dark ? 'text-white/45' : 'text-[#727b8c]'
const studentSlug = makeStudentSlug(studentEmail)
const clonePathPreview = makePlatformWorkspaceRoot(configuredWorkspaceRoot, studentSlug, weekNumber, platformKey)
const clonePathPreview = makePlatformWorkspaceRoot(configuredWorkspaceRoot, studentSlug, courseKey, weekNumber, platformKey)
const remoteRuntimeRoot = `/tmp/autonomy-studio/sessions/${`${platformId}-${studentSlug}-week-${weekNumber}`.replace(/[^a-zA-Z0-9_.-]+/g, '-')}`
const {
visualizationStacks,
@@ -730,7 +731,7 @@ function LocalPcWorkspaceMockup({
curriculumModuleKey,
remoteWorkspace,
remoteWorkspaceTargetKey,
pollingActive: activeIdeActivity === 'modules',
pollingActive: true,
applyModuleParameterOverrides
})
const readGitStatus = useCallback((module: ModuleRow, selectedPath: string | null) => (
@@ -2621,6 +2622,7 @@ function LocalPcWorkspaceMockup({
onDeleteModule={(module, options) => void deleteModule(module, options)}
onUpdateModuleParameters={(module, rosParams) => void updateModuleParameters(module, rosParams)}
onStartModule={(module) => void runModuleControl(module, 'start')}
onBuildModule={(module) => void runModuleControl(module, 'build')}
onStopModule={(module) => void runModuleControl(module, 'stop')}
onRestartModule={(module) => void runModuleControl(module, 'restart')}
onInstallAll={() => void installAllModules()}
@@ -2709,6 +2711,7 @@ export default function App(): React.JSX.Element {
const [studentCoursesLoaded, setStudentCoursesLoaded] = useState(false)
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY) ?? '')
const [courseSelectionVisible, setCourseSelectionVisible] = useState(false)
const [outerView, setOuterView] = useState<'curriculum' | 'guide'>('curriculum')
const [selectedWeekState, setSelectedWeekState] = useState<CurriculumWeek | null>(null)
const [activeLearningStep, setActiveLearningStep] = useState<string | null>(null)
const [curriculumRefreshSignal, setCurriculumRefreshSignal] = useState(0)
@@ -2914,6 +2917,32 @@ export default function App(): React.JSX.Element {
return makeRequest(nextSession.accessToken)
}, [applyAuthSession, clearAuthSession, getFreshAccessToken])
const {
aiPending: guideAiPending,
aiTypingMessageId: guideAiTypingMessageId,
qaMessagesLoading: guideMessagesLoading,
displayedAiMessages: guideAiMessages,
aiConversationHistory: guideAiConversationHistory,
activeAiConversation: guideActiveAiConversation,
selectedAiConversationId: guideSelectedAiConversationId,
viewingAiHistory: guideViewingAiHistory,
chatAvailable: guideChatAvailable,
stopAiResponse: stopGuideAiResponse,
sendQaMessage: sendGuideQaMessage,
selectAiConversation: selectGuideAiConversation,
startNewAiConversation: startGuideAiConversation,
deleteAiConversation: deleteGuideAiConversation
} = useQaChat({
accessToken: authSession?.accessToken ?? '',
weekNumber: 0,
globalScope: true,
apiBaseUrl: LMS_API_BASE_URL,
socketBaseUrl: LMS_SOCKET_ORIGIN,
socketPath: LMS_SOCKET_PATH,
getFreshAccessToken,
onTitlebarNotice: showTitlebarNotice
})
useEffect(() => {
let cancelled = false
@@ -3689,13 +3718,21 @@ export default function App(): React.JSX.Element {
await loadCurriculum()
setPlatformModalOpen(true)
}
const navigateSidebar = (item: 'curriculum' | 'studio' | 'guide' | 'settings'): void => {
const navigateSidebar = (item: 'curriculum' | 'studio' | 'guide'): void => {
if (item === 'curriculum') {
setOuterView('curriculum')
setLocalPcLayoutOpen(false)
setRecentSessionModalOpen(false)
if (!userSidebarCollapsed) setSidebarCollapsed(false)
return
}
if (item === 'guide') {
setOuterView('guide')
setLocalPcLayoutOpen(false)
setRecentSessionModalOpen(false)
setSidebarCollapsed(true)
return
}
if (item === 'studio' && instructorAccount) {
showInstructorStudioGuide()
return
@@ -4006,13 +4043,14 @@ export default function App(): React.JSX.Element {
onOpenCourseSelection={() => {
setCourseSelectionVisible(true)
setLocalPcLayoutOpen(false)
setOuterView('curriculum')
}}
/>
<div className="flex min-h-0 flex-1 bg-[#0a0b0e]">
<Sidebar
collapsed={sidebarCollapsed}
onToggle={toggleSidebar}
activeItem={localPcLayoutOpen ? 'studio' : 'curriculum'}
activeItem={localPcLayoutOpen ? 'studio' : outerView}
studioEnabled={Boolean(selectedCourseId)}
onNavigate={navigateSidebar}
sessionStatus={sidebarRunningSessions.length ? 'running' : sessionStatus}
@@ -4034,7 +4072,9 @@ export default function App(): React.JSX.Element {
<main
className={[
'relative min-w-0 flex-1 rounded-t-[28px] [scrollbar-gutter:stable]',
localPcLayoutOpen
outerView === 'guide'
? 'overflow-hidden bg-[#202020] p-0 text-white'
: localPcLayoutOpen
? ideTheme === 'dark'
? 'overflow-hidden bg-[#202020] p-0 text-[#e6e6e6]'
: 'overflow-hidden bg-[#f4f6fb] p-0 text-[#151827]'
@@ -4084,8 +4124,33 @@ export default function App(): React.JSX.Element {
/>
</div>
)}
<div className={localPcLayoutOpen ? 'hidden' : 'block'}>
{courseSelectionVisible ? (
<div className={localPcLayoutOpen ? 'hidden' : outerView === 'guide' ? 'h-full min-h-0' : 'block'}>
{outerView === 'guide' ? (
<AiGuideView
courseTitle={selectedCourse?.title ?? null}
weekNumber={selectedWeekState?.week ?? null}
weekTitle={selectedWeekState?.title ?? null}
messages={guideAiMessages}
activeConversation={guideActiveAiConversation}
typingMessageId={guideAiTypingMessageId}
history={guideAiConversationHistory}
selectedHistoryId={guideSelectedAiConversationId}
viewingHistory={guideViewingAiHistory}
loading={guideMessagesLoading}
busy={guideAiPending}
disabled={!guideChatAvailable}
onSubmit={(content) => void sendGuideQaMessage(content)}
onQuickSubmit={(content) => void sendGuideQaMessage(content, { contextWeek: selectedWeekState?.week })}
onStop={stopGuideAiResponse}
onClear={() => void startGuideAiConversation()}
onSelectHistory={selectGuideAiConversation}
onDeleteHistory={(threadId) => void deleteGuideAiConversation(threadId)}
onClose={() => {
setOuterView('curriculum')
if (!userSidebarCollapsed) setSidebarCollapsed(false)
}}
/>
) : courseSelectionVisible ? (
<CourseSelectionScreen
courses={studentCourses}
selectedCourseId={selectedCourseId}
@@ -4166,7 +4231,7 @@ export default function App(): React.JSX.Element {
<PlatformSettingsPanel
target={platformSettingsTarget}
draft={platformSettingsDraft}
clonePath={makeWeekWorkspaceRoot(platformSettingsDraft.workspaceRoot, makeStudentSlug(authSession?.user.email ?? 'student'), selectedWeek.week)}
clonePath={makeWeekWorkspaceRoot(platformSettingsDraft.workspaceRoot, makeStudentSlug(authSession?.user.email ?? 'student'), courseKey, selectedWeek.week)}
onDraftChange={setPlatformSettingsDraft}
onClose={closePlatformSettings}
onSave={savePlatformSettings}
+2 -3
View File
@@ -1,11 +1,10 @@
import { Activity, BookOpen, Bot, LayoutDashboard, Menu, Power, Settings } from 'lucide-react'
import { Activity, BookOpen, Bot, LayoutDashboard, Menu, Power } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
const navItems = [
{ id: 'curriculum', label: '커리큘럼', icon: BookOpen },
{ id: 'studio', label: '개발 스튜디오', icon: LayoutDashboard },
{ id: 'guide', label: 'AI 가이드', icon: Bot },
{ id: 'settings', label: '설정', icon: Settings }
{ id: 'guide', label: 'AI 가이드', icon: Bot }
] as const
type NavItemId = (typeof navItems)[number]['id']
+4
View File
@@ -100,6 +100,7 @@ interface Window {
}>
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }>
stopRunning: () => Promise<{ ok: boolean; message: string }>
@@ -177,6 +178,7 @@ interface Window {
}
terminal: {
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
resize: (cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
write: (data: string, sessionId?: string) => void
stop: (sessionId?: string) => void
@@ -239,6 +241,7 @@ interface Window {
}
}>
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
@@ -254,6 +257,7 @@ interface Window {
}
terminal: {
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => Promise<boolean>
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => Promise<boolean>
write: (data: string, sessionId?: string) => void
stop: (sessionId?: string) => void
+97
View File
@@ -0,0 +1,97 @@
import { BookOpen, ChevronRight, Code2, LoaderCircle, MessageSquareText, Plus, Sparkles, Trash2, X } from 'lucide-react'
import type React from 'react'
import { useRef, useState } from 'react'
import { ExpandableMarkdownMessage, QaInputBox } from '../components/ChatWidgets'
import type { QaChatMessage, QaChatThread } from './workspace/useQaChat'
type Props = {
courseTitle: string | null; weekNumber: number | null; weekTitle: string | null
messages: QaChatMessage[]; activeConversation: QaChatThread | null; typingMessageId: string | null
history: QaChatThread[]; selectedHistoryId: string | null; viewingHistory: boolean
loading: boolean; busy: boolean; disabled: boolean
onSubmit: (content: string) => void; onQuickSubmit: (content: string) => void; onStop: () => void; onClear: () => void
onSelectHistory: (threadId: string | null) => void; onDeleteHistory: (threadId: string) => void; onClose: () => void
}
const quickQuestions = [
{ Icon: BookOpen, label: '이번 주차 요약', prompt: '현재 주차의 학습 목표와 진행 순서를 간단히 정리해줘.' },
{ Icon: Code2, label: '실습 시작 방법', prompt: '현재 주차 실습을 시작하기 전에 확인할 내용을 알려줘.' }
]
const formatDate = (value?: string): string => {
if (!value) return ''
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('ko-KR', { month: 'numeric', day: 'numeric' }).format(date)
}
const formatTime = (value?: string): string => {
if (!value) return ''
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' }).format(date)
}
export default function AiGuideView(props: Props): React.JSX.Element {
const {
courseTitle, weekNumber, weekTitle, messages, activeConversation, typingMessageId, history, selectedHistoryId, viewingHistory,
loading, busy, disabled, onSubmit, onQuickSubmit, onStop, onClear, onSelectHistory, onDeleteHistory, onClose
} = props
const [selectedQuestionId, setSelectedQuestionId] = useState<string | null>(null)
const [switchingConversation, setSwitchingConversation] = useState(false)
const [deleteConfirmThread, setDeleteConfirmThread] = useState<QaChatThread | null>(null)
const questionRefs = useRef<Record<string, HTMLElement | null>>({})
const conversationScrollRef = useRef<HTMLDivElement | null>(null)
const contextLabel = weekNumber && weekTitle ? `${weekNumber}주차 · ${weekTitle}` : '선택된 학습 주차 없음'
const latestAssistantMessageId = [...messages].reverse().find((message) => message.role === 'assistant')?.id
const questionMessages = messages.filter((message) => message.role === 'user')
const selectConversation = (threadId: string | null): void => {
setSwitchingConversation(true)
setSelectedQuestionId(null)
onSelectHistory(threadId)
window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
conversationScrollRef.current?.scrollTo({ top: conversationScrollRef.current.scrollHeight, behavior: 'auto' })
window.setTimeout(() => setSwitchingConversation(false), 1000)
}))
}
return (
<section className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#202020] text-white">
<header className="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-[#252525] px-3">
<strong className="truncate pl-4 text-[13px] font-bold text-white/90">AI 가이드</strong>
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded text-white/55 transition hover:bg-white/[0.08] hover:text-white"><X size={16} /></button>
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#202228]">
<div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<div className="rounded-lg border border-[#3b7ffa]/25 bg-[#3b7ffa]/[0.08] px-3 pb-3 pt-2">
<span className="text-[10px] font-bold tracking-[0.08em] text-[#9bbdff]">현재 학습</span>
<strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong>
<p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p>
</div>
<button type="button" disabled={busy || disabled} onClick={onClear} className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Plus size={15} className="shrink-0 text-[#82abff]" /><span className="min-w-0 flex-1">새 대화</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>
<div className="mt-5"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">빠른 질문</span><div className="mt-2 grid gap-1">
{quickQuestions.map(({ Icon, label, prompt }) => <button key={label} type="button" disabled={disabled || busy || !weekNumber} onClick={() => onQuickSubmit(prompt)} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Icon size={15} className="shrink-0 text-[#82abff]" /><span className="min-w-0 flex-1">{label}</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>)}
</div></div>
<div className="mt-6 border-t border-white/[0.08] pt-4"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">최근 대화</span><div className="mt-2 grid gap-1">
{activeConversation ? <button type="button" onClick={() => selectConversation(null)} className={['min-w-0 rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 대화'}</span><span className="mt-0.5 block text-[10px] text-white/32">현재 대화</span></button> : viewingHistory ? <button type="button" onClick={() => selectConversation(null)} className="flex min-h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] font-semibold text-white/48 transition hover:bg-white/[0.06] hover:text-white/75"><MessageSquareText size={13} /><span className="truncate">새 대화로 돌아가기</span></button> : null}
{history.map((thread) => <div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><button type="button" onClick={() => selectConversation(thread.id)} className="min-w-0 flex-1 px-2 py-2 text-left"><span className="block truncate text-[11px] font-semibold">{thread.title || '이전 대화'}</span><span className="mt-0.5 block text-[10px] text-white/32">{formatDate(thread.lastMessageAt)}</span></button><button type="button" title="대화 삭제" aria-label="대화 삭제" onClick={() => setDeleteConfirmThread(thread)} className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"><Trash2 size={13} /></button></div>)}
</div></div>
</div>
</aside>
<main className="relative flex min-w-0 flex-1 flex-col bg-[#181a20]">
<div ref={conversationScrollRef} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-28 pt-7">
{loading ? <div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 대화를 불러오는 중</span></div> : disabled ? <div className="grid h-full min-h-[300px] place-items-center text-center"><div><span className="mx-auto grid size-10 place-items-center rounded-lg bg-white/[0.06] text-white/42"><MessageSquareText size={19} /></span><strong className="mt-3 block text-[14px] text-white/75">AI 가이드를 준비하고 있습니다</strong></div></div> : <div className="mx-auto flex w-full max-w-[860px] flex-col gap-5">
{messages.length === 0 && <div className="py-9 text-center"><span className="mx-auto grid size-11 place-items-center rounded-xl border border-[#3b7ffa]/25 bg-[#3b7ffa]/10 text-[#96b8ff]"><Sparkles size={20} /></span><h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2><p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p></div>}
{messages.map((message) => <article key={message.id} ref={message.role === 'user' ? (node) => { questionRefs.current[message.id] = node } : undefined} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}><div className={message.role === 'user' ? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#3b7ffa] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(32,91,204,0.24)]' : 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#22252c] px-4 py-3'}>{message.role === 'user' ? <p className="whitespace-pre-wrap">{message.content}</p> : <><>{(message.isTyping || message.id === typingMessageId) && <span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-[#8fb4ff]"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>}</><ExpandableMarkdownMessage content={message.content || '응답을 작성하고 있습니다.'} dark defaultCollapsed={!message.keepExpanded && message.id !== latestAssistantMessageId} /></>}</div>{message.status === 'CANCELED' && <p className="mt-1 text-[10px] font-medium text-white/32">응답이 중단됨</p>}</article>)}
{busy && <article className="w-full"><div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#22252c] px-4 py-3 text-[12px] font-semibold text-[#8fb4ff]"><LoaderCircle size={15} className="animate-spin" /> 생각 중</div></article>}
</div>}
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div>
{switchingConversation && <div className="absolute inset-0 z-20 grid place-items-center bg-[#181a20]/78 backdrop-blur-[2px]"><span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252525] px-4 py-3 text-[12px] font-semibold text-white/76"><LoaderCircle size={15} className="animate-spin text-[#8fb4ff]" /> 대화 불러오는 중</span></div>}
</main>
<aside className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#202228]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold tracking-[0.08em] text-white/44">질문 목록</span></div><div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">질문을 보내면 이곳에 표시됩니다.</div>}</div></aside>
</div>
{deleteConfirmThread && <div className="absolute inset-0 z-50 grid place-items-center bg-black/36"><section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#252525] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]"><div className="flex items-center gap-2 border-b border-white/10 px-4 py-3"><Trash2 size={15} className="text-[#e87979]" /><strong className="text-[13px] font-bold">대화 삭제</strong></div><div className="p-4"><p className="text-[13px] font-semibold text-white">이 대화 내역을 삭제할까요?</p><p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 대화'}</p><p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 대화와 답변은 복구할 수 없습니다.</p><div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setDeleteConfirmThread(null)} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/62 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" onClick={() => { onDeleteHistory(deleteConfirmThread.id); setDeleteConfirmThread(null) }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]">삭제</button></div></div></section></div>}
</section>
)
}
@@ -1,4 +1,4 @@
import { Download, FolderOpen, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
import { Download, FolderOpen, Hammer, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import type React from 'react'
@@ -25,6 +25,7 @@ type ModuleListRowProps<Module extends ModuleListRowItem> = {
onInstallModule: (module: Module) => void
onDeleteModule: (module: Module) => void
onStartModule: (module: Module) => void
onBuildModule: (module: Module) => void
onStopModule: (module: Module) => void
onRestartModule: (module: Module) => void
}
@@ -85,6 +86,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
onInstallModule,
onDeleteModule,
onStartModule,
onBuildModule,
onStopModule,
onRestartModule
}: ModuleListRowProps<Module>): React.JSX.Element {
@@ -105,8 +107,16 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
{ label: '재시작', Icon: RefreshCw, tone: 'neutral', action: onRestartModule },
{ label: '중지', Icon: Square, tone: 'warning', action: onStopModule }
]
: module.state === 'stopped'
? [{ label: '실행', Icon: Play, tone: 'start', action: onStartModule }]
: module.state === 'stopped' && controlling
? [
{ label: '빌드', Icon: Hammer, tone: 'neutral', action: onBuildModule },
{ label: '중지', Icon: Square, tone: 'warning', action: onStopModule }
]
: module.state === 'stopped'
? [
{ label: '빌드', Icon: Hammer, tone: 'neutral', action: onBuildModule },
{ label: '실행', Icon: Play, tone: 'start', action: onStartModule }
]
: []
const installAction = module.state === 'uninstalled'
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
@@ -203,20 +213,26 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
</button>
)}
{controlActions.map(({ label, Icon, tone, action }) => (
<button
key={`${module.name}-${label}`}
type="button"
disabled={busy}
onClick={(event) => {
event.stopPropagation()
action(module)
}}
aria-label={label}
className={actionButtonClass(dark, tone, busy)}
>
{busy ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
<TooltipLabel dark={dark}>{label}</TooltipLabel>
</button>
(() => {
const canCancelBuild = module.state === 'stopped' && controlling && label === '중지'
const disabled = busy && !canCancelBuild
return (
<button
key={`${module.name}-${label}`}
type="button"
disabled={disabled}
onClick={(event) => {
event.stopPropagation()
action(module)
}}
aria-label={label}
className={actionButtonClass(dark, tone, disabled)}
>
{busy && !canCancelBuild ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
<TooltipLabel dark={dark}>{label}</TooltipLabel>
</button>
)
})()
))}
</span>
<span className="flex justify-center">
@@ -23,6 +23,7 @@ type ModuleListViewProps = {
onDeleteModule: (module: ModuleRow, options: { deleteRepository: boolean }) => void
onUpdateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => void
onStartModule: (module: ModuleRow) => void
onBuildModule: (module: ModuleRow) => void
onStopModule: (module: ModuleRow) => void
onRestartModule: (module: ModuleRow) => void
onInstallAll: () => void
@@ -44,6 +45,7 @@ export default function ModuleListView({
onDeleteModule,
onUpdateModuleParameters,
onStartModule,
onBuildModule,
onStopModule,
onRestartModule,
onInstallAll,
@@ -134,6 +136,7 @@ export default function ModuleListView({
onInstallModule={onInstallModule}
onDeleteModule={setDeleteConfirmModule}
onStartModule={onStartModule}
onBuildModule={onBuildModule}
onStopModule={onStopModule}
onRestartModule={onRestartModule}
/>
@@ -35,7 +35,7 @@ function WorkspaceTerminalPanel({
</div>
<div
ref={terminalHostRef}
className={['overflow-hidden px-2 py-1', dark ? 'bg-[#171717]' : 'bg-[#f9fafc]'].join(' ')}
className={['box-border overflow-hidden px-2 py-1', dark ? 'bg-[#171717]' : 'bg-[#f9fafc]'].join(' ')}
style={{ height: Math.max(110, terminalPanelHeight - 41) }}
/>
</footer>
@@ -9,18 +9,22 @@ export const defaultStudioSourceSettings: StudioSourceSettings = {
rosDomainId: '77'
}
export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string, weekNumber: number): string {
function normalizeCoursePathSegment(courseKey: string): string {
return courseKey.trim().replace(/[^a-zA-Z0-9_-]+/g, '-') || 'default-course'
}
export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
const suffix = `${studentSlug}/week-${weekNumber}`
const suffix = `${studentSlug}/${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) {
return normalizedRoot
}
return `${normalizedRoot}/${suffix}`
}
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, weekNumber: number, platformKey: string): string {
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, weekNumber)
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber)
const safePlatformKey = platformKey
.replace(/^futuredrive[-_]/i, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
+114 -19
View File
@@ -15,10 +15,20 @@ export type QaChatMessage = {
status?: 'ACTIVE' | 'CANCELED'
}
export type QaChatThread = {
id: string
title: string
status: 'OPEN' | 'WAITING_INSTRUCTOR' | 'RESOLVED' | 'ARCHIVED'
lastMessageAt?: string
messages: QaChatMessage[]
}
type ChatThreadResponse = {
id: string
type: 'AI' | 'INSTRUCTOR' | 'MIXED'
status: 'OPEN' | 'WAITING_INSTRUCTOR' | 'RESOLVED' | 'ARCHIVED'
status: QaChatThread['status']
title: string
lastMessageAt?: string
messages: Array<{
id: string
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM'
@@ -43,6 +53,7 @@ type ChatNotificationPayload = {
type UseQaChatOptions = {
accessToken: string
weekNumber: number
globalScope?: boolean
apiBaseUrl: string
socketBaseUrl: string
socketPath: string
@@ -63,6 +74,7 @@ const qaVisibleMessageLimit = 20
export function useQaChat({
accessToken,
weekNumber,
globalScope = false,
apiBaseUrl,
socketBaseUrl,
socketPath,
@@ -71,12 +83,15 @@ export function useQaChat({
}: UseQaChatOptions) {
const [qaMode, setQaMode] = useState<QaMode>('ai')
const [aiMessages, setAiMessages] = useState<QaChatMessage[]>(initialAiChatMessages)
const [aiConversations, setAiConversations] = useState<QaChatThread[]>([])
const [activeAiConversationId, setActiveAiConversationId] = useState<string | null>(null)
const [selectedAiConversationId, setSelectedAiConversationId] = useState<string | null>(null)
const [teacherMessages, setTeacherMessages] = useState<QaChatMessage[]>([])
const [teacherThreadStatus, setTeacherThreadStatus] = useState<ChatThreadResponse['status'] | null>(null)
const [chatRefreshSignal, setChatRefreshSignal] = useState(0)
const [teacherChatFlash, setTeacherChatFlash] = useState(false)
const [aiMessagesLoading, setAiMessagesLoading] = useState(Boolean(accessToken && weekNumber >= 1))
const [teacherMessagesLoading, setTeacherMessagesLoading] = useState(Boolean(accessToken && weekNumber >= 1))
const [aiMessagesLoading, setAiMessagesLoading] = useState(Boolean(accessToken && (globalScope || weekNumber >= 1)))
const [teacherMessagesLoading, setTeacherMessagesLoading] = useState(Boolean(accessToken && (globalScope || weekNumber >= 1)))
const [aiPending, setAiPending] = useState(false)
const [aiTypingMessageId, setAiTypingMessageId] = useState<string | null>(null)
const [qaCollapseSignal, setQaCollapseSignal] = useState(0)
@@ -85,7 +100,9 @@ export function useQaChat({
const aiRevealCancelRef = useRef(false)
const expandedAiMessageIdsRef = useRef<Set<string>>(new Set())
const validChatWeek = Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null
const validChatWeek = globalScope ? null : Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null
const chatAvailable = globalScope || validChatWeek !== null
const chatWeekQuery = validChatWeek ? `&week=${validChatWeek}` : ''
const requestChatApi = async <T,>(path: string, options?: RequestInit): Promise<T> => {
const requestAccessToken = accessToken ? await getFreshAccessToken() : null
@@ -141,7 +158,7 @@ export function useQaChat({
}, [accessToken, onTitlebarNotice, socketBaseUrl, socketPath])
useEffect(() => {
if (!accessToken || !validChatWeek) {
if (!accessToken || !chatAvailable) {
setAiMessagesLoading(false)
setTeacherMessagesLoading(false)
return
@@ -149,11 +166,22 @@ export function useQaChat({
let cancelled = false
setAiMessagesLoading(true)
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=ai&week=${validChatWeek}`)
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=ai${chatWeekQuery}&includeArchived=true`)
.then((payload) => {
if (cancelled) return
const messages = payload.threads[0]?.messages ?? []
setAiMessages(messages.length ? toQaMessages(messages) : initialAiChatMessages)
const conversations = payload.threads.map((thread) => ({
id: thread.id,
title: thread.title,
status: thread.status,
lastMessageAt: thread.lastMessageAt,
messages: toQaMessages(thread.messages)
}))
const activeConversation = conversations.find((thread) => thread.status !== 'ARCHIVED')
setAiConversations(conversations)
setActiveAiConversationId(activeConversation?.id ?? null)
setSelectedAiConversationId(null)
const messages = activeConversation?.messages ?? []
setAiMessages(messages.length ? messages : initialAiChatMessages)
})
.catch(() => {
if (!cancelled) setAiMessages(initialAiChatMessages)
@@ -163,7 +191,7 @@ export function useQaChat({
})
setTeacherMessagesLoading(true)
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=teacher&week=${validChatWeek}`)
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=teacher${chatWeekQuery}`)
.then((payload) => {
if (cancelled) return
const thread = payload.threads[0]
@@ -183,7 +211,7 @@ export function useQaChat({
return () => {
cancelled = true
}
}, [accessToken, chatRefreshSignal, validChatWeek])
}, [accessToken, chatAvailable, chatRefreshSignal, chatWeekQuery])
useEffect(() => {
const target = workspaceChatScrollRef.current
@@ -236,7 +264,7 @@ export function useQaChat({
setAiPending(false)
void requestChatApi<{ message: ChatThreadResponse['messages'][number] | null }>('/chat/ai/cancel-active', {
method: 'POST',
body: JSON.stringify({ week: validChatWeek })
body: JSON.stringify(validChatWeek ? { week: validChatWeek } : {})
}).catch(() => {})
setAiMessages((messages) => [
...messages,
@@ -250,9 +278,9 @@ export function useQaChat({
}
}
const sendQaMessage = async (rawContent: string): Promise<void> => {
const sendQaMessage = async (rawContent: string, options?: { contextWeek?: number }): Promise<void> => {
const content = rawContent.trim()
if (!content || aiPending || aiTypingMessageId || !validChatWeek) return
if (!content || aiPending || aiTypingMessageId || !chatAvailable) return
setQaCollapseSignal((value) => value + 1)
if (qaMode !== 'ai') {
@@ -266,7 +294,7 @@ export function useQaChat({
try {
const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string } }>('/chat/messages', {
method: 'POST',
body: JSON.stringify({ mode: 'teacher', content, week: validChatWeek })
body: JSON.stringify({ mode: 'teacher', content, ...(validChatWeek ? { week: validChatWeek } : {}) })
})
setTeacherMessages((messages) => messages.map((message) => (
message.id === userMessage.id
@@ -290,7 +318,8 @@ export function useQaChat({
const userMessage: QaChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
content
content,
createdAt: new Date().toISOString()
}
const nextMessages = [...aiMessages, userMessage]
@@ -301,14 +330,36 @@ export function useQaChat({
aiAbortControllerRef.current = abortController
try {
const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string }; content: string; canceled?: boolean }>('/chat/ai/respond', {
const response = await requestChatApi<{ threadId: string; threadTitle?: string | null; message: { id: string; content: string; createdAt: string }; content: string; canceled?: boolean }>('/chat/ai/respond', {
method: 'POST',
body: JSON.stringify({ content, week: validChatWeek }),
body: JSON.stringify({
content,
...(validChatWeek ? { week: validChatWeek } : {}),
...(options?.contextWeek ? { contextWeek: options.contextWeek } : {})
}),
signal: abortController.signal
})
aiAbortControllerRef.current = null
if (aiRevealCancelRef.current || response.canceled) return
const fullContent = response.content ?? ''
setActiveAiConversationId(response.threadId)
setAiConversations((threads) => {
const assistantMessage: QaChatMessage = {
id: response.message.id,
role: 'assistant',
content: fullContent,
createdAt: response.message.createdAt,
keepExpanded: true
}
const nextThread: QaChatThread = {
id: response.threadId,
title: response.threadTitle ?? threads.find((thread) => thread.id === response.threadId)?.title ?? content.slice(0, 42),
status: 'OPEN',
lastMessageAt: response.message.createdAt,
messages: [...nextMessages, assistantMessage]
}
return [nextThread, ...threads.filter((thread) => thread.id !== response.threadId)]
})
expandedAiMessageIdsRef.current.add(response.message.id)
setAiPending(false)
setAiTypingMessageId(response.message.id)
@@ -369,7 +420,7 @@ export function useQaChat({
}
const clearQaConversation = async (): Promise<void> => {
if (!validChatWeek) return
if (!chatAvailable) return
if (qaMode === 'ai') {
aiRevealCancelRef.current = true
aiAbortControllerRef.current?.abort()
@@ -378,11 +429,20 @@ export function useQaChat({
setAiTypingMessageId(null)
}
await requestChatApi<{ deletedCount: number }>(`/chat/threads?mode=${qaMode}&week=${validChatWeek}`, {
await requestChatApi<{ deletedCount: number }>(`/chat/threads?mode=${qaMode}${chatWeekQuery}`, {
method: 'DELETE'
})
if (qaMode === 'ai') {
if (activeAiConversationId) {
setAiConversations((threads) => threads.map((thread) => (
thread.id === activeAiConversationId
? { ...thread, status: 'ARCHIVED', messages: aiMessages }
: thread
)))
}
setActiveAiConversationId(null)
setSelectedAiConversationId(null)
setAiMessages(initialAiChatMessages)
return
}
@@ -396,6 +456,28 @@ export function useQaChat({
if (mode === 'teacher') setTeacherChatFlash(false)
}
const selectedAiConversation = selectedAiConversationId
? aiConversations.find((thread) => thread.id === selectedAiConversationId) ?? null
: null
const selectAiConversation = (threadId: string | null): void => {
setSelectedAiConversationId(threadId)
}
const startNewAiConversation = async (): Promise<void> => {
if (activeAiConversationId) {
await clearQaConversation()
return
}
setSelectedAiConversationId(null)
}
const deleteAiConversation = async (threadId: string): Promise<void> => {
await requestChatApi<{ id: string }>(`/chat/threads/${threadId}`, { method: 'DELETE' })
setAiConversations((threads) => threads.filter((thread) => thread.id !== threadId))
if (selectedAiConversationId === threadId) setSelectedAiConversationId(null)
}
return {
qaMode,
setQaMode: setMode,
@@ -407,8 +489,21 @@ export function useQaChat({
qaCollapseSignal,
qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading,
visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit),
displayedAiMessages: selectedAiConversation
? selectedAiConversation.messages
: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit),
aiConversationHistory: aiConversations.filter((thread) => thread.status === 'ARCHIVED'),
activeAiConversation: activeAiConversationId
? aiConversations.find((thread) => thread.id === activeAiConversationId) ?? null
: null,
selectedAiConversationId,
viewingAiHistory: Boolean(selectedAiConversation),
selectAiConversation,
startNewAiConversation,
deleteAiConversation,
visibleTeacherMessages: teacherMessages.slice(-qaVisibleMessageLimit),
validChatWeek,
chatAvailable,
workspaceChatScrollRef,
stopAiResponse,
sendQaMessage,
@@ -38,7 +38,7 @@ type UseWorkspaceModuleControlResult = {
installAllModules: () => Promise<void>
startAllModules: () => Promise<void>
stopAllModules: () => Promise<void>
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart') => Promise<void>
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<void>
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
}
@@ -229,7 +229,7 @@ export function useWorkspaceModuleControl({
}
}, [availableModules, clonePathPreview, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteModuleStop, remoteWorkspace])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart'): Promise<void> => {
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
if (installingModuleName || controllingModuleNames.includes(module.name)) return
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
@@ -239,7 +239,11 @@ export function useWorkspaceModuleControl({
? remoteWorkspace
? await remoteModuleStop(module)
: await window.localWorkspace.modules.stop(module.name, clonePathPreview, module.folderName)
: action === 'restart'
: action === 'build'
? remoteWorkspace
? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
: action === 'restart'
? remoteWorkspace
? await remoteModuleRestart(module)
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
@@ -250,7 +254,9 @@ export function useWorkspaceModuleControl({
await loadAvailableModules({ silent: true })
const actionLabel = action === 'stop'
? '종료되었습니다'
: action === 'restart'
: action === 'build'
? '빌드되었습니다'
: action === 'restart'
? '재시작되었습니다'
: '실행되었습니다'
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
@@ -259,7 +265,9 @@ export function useWorkspaceModuleControl({
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
const actionLabel = action === 'stop'
? '종료 실패'
: action === 'restart'
: action === 'build'
? '빌드 실패'
: action === 'restart'
? '재시작 실패'
: '실행 실패'
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
@@ -74,7 +74,10 @@ export function useWorkspaceTerminal({
if (text) window.localWorkspace.terminal.write(text, terminalSessionId)
})
.catch((error) => console.error('[terminal] paste failed', error))
.finally(() => setTerminalContextMenu(null))
.finally(() => {
setTerminalContextMenu(null)
window.requestAnimationFrame(() => terminalRef.current?.focus())
})
}
const startTerminalResize = (event: React.MouseEvent<HTMLDivElement>): void => {
@@ -123,7 +126,15 @@ export function useWorkspaceTerminal({
term.loadAddon(fitAddon)
term.open(terminalHostRef.current)
fitAddon.fit()
term.focus()
term.attachCustomKeyEventHandler((event) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) {
event.stopPropagation()
}
return true
})
const terminalElement = terminalHostRef.current
const focusTerminal = (): void => term.focus()
const handleTerminalContextMenu = (event: MouseEvent): void => {
const selection = term.getSelection().trim()
event.preventDefault()
@@ -134,6 +145,7 @@ export function useWorkspaceTerminal({
})
}
terminalElement.addEventListener('contextmenu', handleTerminalContextMenu)
terminalElement.addEventListener('mousedown', focusTerminal)
let terminalReceivedVisibleData = Boolean(terminalOutputBufferRef.current[terminalSessionId])
if (terminalOutputBufferRef.current[terminalSessionId]) {
term.write(terminalOutputBufferRef.current[terminalSessionId])
@@ -162,10 +174,29 @@ export function useWorkspaceTerminal({
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
let fallbackPromptTimer: number | null = null
const startTerminal = window.localWorkspace.terminal.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined)
let sizeSyncTimer: number | null = null
let startFrame: number | null = null
let startNestedFrame: number | null = null
let disposed = false
const startTerminal = new Promise<boolean>((resolvePromise) => {
startFrame = window.requestAnimationFrame(() => {
startNestedFrame = window.requestAnimationFrame(() => {
fitAddon.fit()
if (disposed) return resolvePromise(false)
void window.localWorkspace.terminal
.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined)
.then(resolvePromise)
.catch(() => resolvePromise(false))
})
})
})
void startTerminal.then((started) => {
fitAddon.fit()
if (!started) return
sizeSyncTimer = window.setTimeout(() => {
fitAddon.fit()
void terminalApi.resize(term.cols, term.rows, terminalSessionId)
}, 150)
fallbackPromptTimer = window.setTimeout(() => {
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
if (terminalReceivedVisibleData || terminalOutputBufferRef.current[terminalSessionId]) return
@@ -183,12 +214,21 @@ export function useWorkspaceTerminal({
window.addEventListener('resize', onResize)
return () => {
disposed = true
if (startFrame !== null) window.cancelAnimationFrame(startFrame)
if (startNestedFrame !== null) window.cancelAnimationFrame(startNestedFrame)
window.removeEventListener('resize', onResize)
terminalElement.removeEventListener('contextmenu', handleTerminalContextMenu)
terminalElement.removeEventListener('mousedown', focusTerminal)
removeDataListener()
removeSuppressListener()
inputDisposable.dispose()
if (fallbackPromptTimer !== null) window.clearTimeout(fallbackPromptTimer)
if (sizeSyncTimer !== null) window.clearTimeout(sizeSyncTimer)
// Full-screen programs such as vi cannot be restored from a partial raw output buffer.
delete terminalOutputBufferRef.current[terminalSessionId]
terminalFallbackPromptShownRef.current.delete(terminalSessionId)
terminalApi.stop(terminalSessionId)
term.dispose()
terminalRef.current = null
terminalFitRef.current = null