diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index 0b8d9b0..1108547 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -3042,6 +3042,19 @@ async function cancelRunnerCodexExecution(requestId: string) { } } +function isRunnerTerminatedMessage(message: string) { + const normalizedMessage = message.trim().toLowerCase(); + return normalizedMessage === 'terminated' || normalizedMessage.endsWith(': terminated'); +} + +function normalizeRunnerExecutionErrorMessage(message: string) { + if (isRunnerTerminatedMessage(message)) { + return 'Codex 실행이 중간에 종료되었습니다. 잠시 후 다시 시도해 주세요.'; + } + + return message; +} + async function runAgenticCodexReply( context: ChatContext | null, input: string, @@ -3441,7 +3454,16 @@ async function runAgenticCodexReply( } if (remoteErrorMessage) { - reject(new Error(remoteErrorMessage)); + const hasMeaningfulOutput = Boolean( + completedAgentMessage.trim() || streamedOutput.trim() || stdoutTail.trim(), + ); + + if (isRunnerTerminatedMessage(remoteErrorMessage) && hasMeaningfulOutput) { + resolve(); + return; + } + + reject(new Error(normalizeRunnerExecutionErrorMessage(remoteErrorMessage))); return; } diff --git a/scripts/run-server-command-runner.mjs b/scripts/run-server-command-runner.mjs index f279033..197092d 100644 --- a/scripts/run-server-command-runner.mjs +++ b/scripts/run-server-command-runner.mjs @@ -58,6 +58,48 @@ const CODEX_LIVE_FINISHED_RETENTION_MS = Math.max( ); const activeCodexExecutions = new Map(); const recentCodexExecutions = new Map(); +let runnerShutdownSignal = null; + +function logRunner(message) { + process.stdout.write("[server-command-runner] " + new Date().toISOString() + " " + message + "\n"); +} + +function summarizeActiveExecutionIds(limit = 8) { + const requestIds = Array.from(activeCodexExecutions.keys()).slice(0, limit); + const suffix = activeCodexExecutions.size > requestIds.length ? " +" + (activeCodexExecutions.size - requestIds.length) + " more" : ""; + return requestIds.length > 0 ? requestIds.join(", ") + suffix : "none"; +} + +function resolveSignalExitCode(signal) { + switch (signal) { + case "SIGINT": + return 130; + case "SIGHUP": + return 129; + case "SIGTERM": + return 143; + default: + return 1; + } +} + +function shutdownRunnerFromSignal(signal) { + if (runnerShutdownSignal) { + return; + } + + runnerShutdownSignal = signal; + logRunner("received " + signal + "; activeExecutions=" + activeCodexExecutions.size + "; requestIds=" + summarizeActiveExecutionIds()); + process.exit(resolveSignalExitCode(signal)); +} + +process.once("SIGTERM", () => shutdownRunnerFromSignal("SIGTERM")); +process.once("SIGINT", () => shutdownRunnerFromSignal("SIGINT")); +process.once("SIGHUP", () => shutdownRunnerFromSignal("SIGHUP")); +process.on("exit", (code) => { + const shutdownLabel = runnerShutdownSignal === null ? "none" : runnerShutdownSignal; + logRunner("exiting with code " + code + "; shutdownSignal=" + shutdownLabel + "; activeExecutions=" + activeCodexExecutions.size); +}); function resolveCodexLiveModel(value) { const normalized = String(value ?? '').trim(); @@ -798,6 +840,7 @@ async function runCodexLiveExecution(payload, response) { child, tempDir, }); + logRunner("spawned Codex child pid=" + (child.pid ?? "unknown") + " requestId=" + requestId + " sessionId=" + sessionId + " model=" + codexModel + " idleTimeoutMs=" + configuredIdleTimeoutMs + " maxExecutionMs=" + configuredMaxExecutionMs); activeCodexExecutions.set(requestId, executionRecord); attachCodexExecutionSubscriber(executionRecord, response); broadcastCodexExecutionEvent(executionRecord, { @@ -820,7 +863,7 @@ async function runCodexLiveExecution(payload, response) { } }; - const requestTermination = (message) => { + const requestTermination = (message, reason = 'runner-termination') => { if (terminationRequested) { return; } @@ -833,6 +876,7 @@ async function runCodexLiveExecution(payload, response) { message, }); + logRunner("terminating Codex child pid=" + (child.pid ?? "unknown") + " requestId=" + requestId + " reason=" + reason + " message=" + message); child.kill('SIGTERM'); setTimeout(() => { if (!child.killed) { @@ -853,6 +897,7 @@ async function runCodexLiveExecution(payload, response) { idleTimer = setTimeout(() => { requestTermination( `Codex Live 실행이 ${Math.round(configuredIdleTimeoutMs / 1000)}초 동안 출력이 없어 중단되었습니다.`, + 'idle-timeout', ); }, configuredIdleTimeoutMs); idleTimer.unref?.(); @@ -861,6 +906,7 @@ async function runCodexLiveExecution(payload, response) { executionTimer = setTimeout(() => { requestTermination( `Codex Live 실행이 ${Math.round(configuredMaxExecutionMs / 1000)}초를 넘어 중단되었습니다.`, + 'max-execution-timeout', ); }, configuredMaxExecutionMs); executionTimer.unref?.(); @@ -968,6 +1014,7 @@ async function runCodexLiveExecution(payload, response) { child.on('error', async (error) => { clearExecutionTimers(); + logRunner("Codex child process error requestId=" + requestId + " pid=" + (child.pid ?? "unknown") + " message=" + (error instanceof Error ? error.message : String(error))); broadcastCodexExecutionEvent(executionRecord, { type: 'error', message: error instanceof Error ? error.message : String(error), @@ -979,8 +1026,9 @@ async function runCodexLiveExecution(payload, response) { finalizeCodexExecution(executionRecord); }); - child.on('close', async (code) => { + child.on('close', async (code, signal) => { clearExecutionTimers(); + logRunner("Codex child closed requestId=" + requestId + " pid=" + (child.pid ?? "unknown") + " exitCode=" + (code ?? "null") + " signal=" + (signal ?? "none") + " terminationRequested=" + terminationRequested); const trailingLine = jsonLineBuffer.trim(); if (trailingLine) { handleCodexJsonLine(trailingLine); @@ -1204,6 +1252,7 @@ const server = createServer(async (request, response) => { } try { + logRunner("received cancel request for requestId=" + requestId + "; forwarding SIGTERM to child pid=" + (activeExecution.child.pid ?? "unknown")); activeExecution.child.kill('SIGTERM'); setTimeout(() => { const current = activeCodexExecutions.get(requestId); @@ -1239,5 +1288,5 @@ server.listen(port, host, () => { }); }, 10_000); heartbeatTimer.unref(); - process.stdout.write(`server-command-runner listening on http://${host}:${port}\n`); + logRunner("listening on http://" + host + ":" + port + "; pid=" + process.pid + "; ppid=" + process.ppid + "; startedAt=" + startedAt + "; logFile=" + runnerLogFile); }); diff --git a/scripts/server-command-runner-supervisor.sh b/scripts/server-command-runner-supervisor.sh old mode 100644 new mode 100755 diff --git a/src/app/main/pages/ChatSharePage.css b/src/app/main/pages/ChatSharePage.css index 566ee8f..3d14984 100644 --- a/src/app/main/pages/ChatSharePage.css +++ b/src/app/main/pages/ChatSharePage.css @@ -235,41 +235,46 @@ min-width: 0; } -.chat-share-page__room-swipe { +.chat-share-page__room-item { position: relative; - overflow: hidden; width: 100%; border-radius: 14px; - isolation: isolate; -webkit-tap-highlight-color: transparent; } .chat-share-page__room-delete-action { position: absolute; - inset: 0 0 0 auto; + top: 9px; + right: 9px; display: flex; align-items: center; justify-content: center; - width: 96px; + width: 26px; + height: 26px; border: 0; - border-radius: 14px; - background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%); + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: #dc2626; + font-size: 12px; + box-shadow: + inset 0 0 0 1px rgba(248, 113, 113, 0.26), + 0 6px 14px rgba(15, 23, 42, 0.08); + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; + z-index: 2; +} + +.chat-share-page__room-delete-action:hover, +.chat-share-page__room-delete-action:focus-visible { + background: #dc2626; color: #fff; - font-size: 1rem; - opacity: 0; - visibility: hidden; - pointer-events: none; - transition: opacity 0.2s ease, visibility 0.2s ease; + box-shadow: + inset 0 0 0 1px rgba(220, 38, 38, 0.18), + 0 8px 16px rgba(220, 38, 38, 0.22); + transform: translateY(-1px); } -.chat-share-page__room-swipe.is-swiped .chat-share-page__room-delete-action, -.chat-share-page__room-swipe.is-dragging .chat-share-page__room-delete-action { - opacity: 1; - visibility: visible; - pointer-events: auto; -} - -.chat-share-page__room-swipe.is-delete-locked .chat-share-page__room-delete-action { +.chat-share-page__room-item.is-delete-locked .chat-share-page__room-delete-action { display: none; } @@ -280,7 +285,7 @@ z-index: 1; width: 100%; min-width: 0; - padding: 12px; + padding: 12px 42px 12px 12px; border: 0; border-radius: 14px; background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%); @@ -290,10 +295,7 @@ text-align: left; cursor: pointer; transition: box-shadow 0.2s ease, background 0.2s ease, transform 0.16s ease; - touch-action: pan-y; will-change: transform; - backface-visibility: hidden; - transform: translate3d(0, 0, 0); } .chat-share-page__room-card--active { @@ -303,10 +305,6 @@ 0 10px 24px rgba(59, 130, 246, 0.16); } -.chat-share-page__room-swipe.is-dragging .chat-share-page__room-card { - transition: box-shadow 0.2s ease, background 0.2s ease; -} - .chat-share-page__room-card--default { background: linear-gradient(180deg, #f4fbff 0%, #e3f4ff 100%); @@ -1223,6 +1221,332 @@ flex-wrap: wrap; } +.chat-share-page__process-inspector { + position: fixed; + top: 0; + left: 0; + width: min(920px, calc(100vw - 24px)); + max-height: min(720px, calc(100dvh - 24px)); + display: grid; + grid-template-rows: auto minmax(0, 1fr); + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 22px; + background: rgba(248, 250, 252, 0.98); + box-shadow: 0 24px 60px rgba(15, 23, 42, 0.24); + overflow: hidden; + backdrop-filter: blur(16px); +} + +.chat-share-page__process-inspector--fullscreen { + inset: 0; + width: 100vw; + max-height: 100dvh; + height: 100dvh; + border-radius: 0; + border-width: 0; +} + +.chat-share-page__process-inspector--minimized { + width: min(380px, calc(100vw - 24px)); + max-height: none; +} + +.chat-share-page__process-inspector-drag { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + background: + linear-gradient(135deg, rgba(12, 74, 110, 0.96), rgba(30, 64, 175, 0.94)), + linear-gradient(90deg, rgba(56, 189, 248, 0.22), rgba(125, 211, 252, 0)); + cursor: grab; + user-select: none; +} + +.chat-share-page__process-inspector-drag:active { + cursor: grabbing; +} + +.chat-share-page__process-inspector-drag-copy { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.chat-share-page__process-inspector-drag-grip { + width: 16px; + height: 16px; + border-radius: 999px; + background: + radial-gradient(circle, rgba(71, 85, 105, 0.8) 1.1px, transparent 1.2px) center / 5px 5px, + rgba(226, 232, 240, 0.85); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.28); + flex: 0 0 auto; +} + +.chat-share-page__process-inspector-drag-text { + display: grid; + min-width: 0; +} + +.chat-share-page__process-inspector-drag-text .ant-typography { + margin: 0; + color: rgba(255, 255, 255, 0.96); +} + +.chat-share-page__process-inspector-request-id { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: rgba(191, 219, 254, 0.96) !important; +} + +.chat-share-page__process-inspector-window-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.chat-share-page__process-inspector-window-button.ant-btn { + width: 30px; + min-width: 30px; + height: 30px; + border-radius: 999px; + color: rgba(255, 255, 255, 0.92); + background: rgba(15, 23, 42, 0.14); +} + +.chat-share-page__process-inspector-body { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-height: 0; +} + +.chat-share-page__process-inspector-summary { + display: grid; + gap: 12px; + padding: 14px 18px 14px; + border-bottom: 1px solid rgba(226, 232, 240, 0.9); +} + +.chat-share-page__process-inspector-summary .ant-typography { + margin: 0; +} + +.chat-share-page__process-inspector-summary-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} + +.chat-share-page__process-inspector-summary-head-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex-wrap: wrap; +} + +.chat-share-page__process-inspector-summary-toggle.ant-btn { + border-radius: 999px; + color: rgba(30, 41, 59, 0.92); + background: rgba(226, 232, 240, 0.72); +} + +.chat-share-page__process-inspector-sections { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + align-content: start; + align-items: start; + gap: 12px; + padding: 14px 18px 18px; + min-height: 0; + overflow: auto; +} + +.chat-share-page__process-inspector-section { + display: grid; + gap: 6px; + min-height: 0; +} + +.chat-share-page__process-inspector-section--checklist, +.chat-share-page__process-inspector-section--narratives { + grid-column: 1; +} + +.chat-share-page__process-inspector-section--log { + grid-column: 2; + grid-row: 1 / span 2; +} + +.chat-share-page__process-inspector-section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} + +.chat-share-page__process-inspector-checklist, +.chat-share-page__process-inspector-narratives, +.chat-share-page__process-inspector-log { + display: grid; + gap: 0; + border: 1px solid rgba(203, 213, 225, 0.88); + border-radius: 16px; + background: rgba(255, 255, 255, 0.92); + overflow: hidden; +} + +.chat-share-page__process-inspector-summary-table { + display: grid; + gap: 0; + border: 1px solid rgba(203, 213, 225, 0.88); + border-radius: 16px; + background: rgba(255, 255, 255, 0.92); + overflow: hidden; +} + +.chat-share-page__process-inspector-table-row, +.chat-share-page__process-inspector-narrative, +.chat-share-page__process-inspector-log-line { + display: grid; + grid-template-columns: 104px minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 10px 12px; +} + +.chat-share-page__process-inspector-check-item { + display: grid; + grid-template-columns: minmax(0, 132px) auto minmax(0, 1fr); + gap: 10px; + align-items: center; + padding: 8px 12px; +} + +.chat-share-page__process-inspector-table-row + .chat-share-page__process-inspector-table-row, +.chat-share-page__process-inspector-narrative + .chat-share-page__process-inspector-narrative, +.chat-share-page__process-inspector-log-line + .chat-share-page__process-inspector-log-line { + border-top: 1px solid rgba(226, 232, 240, 0.9); +} + +.chat-share-page__process-inspector-check-item + .chat-share-page__process-inspector-check-item { + border-top: 1px solid rgba(226, 232, 240, 0.9); +} + +.chat-share-page__process-inspector-table-label { + min-width: 0; + font-size: 12px; + line-height: 1.5; +} + +.chat-share-page__process-inspector-table-value { + min-width: 0; + line-height: 1.55; + word-break: break-word; +} + +.chat-share-page__process-inspector-table-value--mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace; + font-size: 12px; +} + +.chat-share-page__process-inspector-check-title { + min-width: 0; +} + +.chat-share-page__process-inspector-check-note { + min-width: 0; + line-height: 1.45; +} + +.chat-share-page__process-inspector-log { + background: rgba(2, 6, 23, 0.96); + border-color: rgba(30, 41, 59, 0.98); + min-height: 0; + align-content: start; + overflow: auto; +} + +.chat-share-page__process-inspector-log-line { + grid-template-columns: 34px minmax(0, 1fr); + color: rgba(226, 232, 240, 0.96); + font-size: 12px; + line-height: 1.55; +} + +.chat-share-page__process-inspector-log-index { + color: rgba(125, 211, 252, 0.88); + font-variant-numeric: tabular-nums; +} + +.chat-share-page__process-inspector-log-text { + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 960px) { + .chat-share-page__process-inspector { + width: min(100vw - 16px, 720px); + } + + .chat-share-page__process-inspector-sections { + grid-template-columns: minmax(0, 1fr); + } + + .chat-share-page__process-inspector-section--checklist, + .chat-share-page__process-inspector-section--narratives, + .chat-share-page__process-inspector-section--log { + grid-column: 1; + grid-row: auto; + } +} + +.chat-share-page__process-inspector-minimized { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 14px 14px; +} + +.chat-share-page__process-inspector-minimized-copy { + display: grid; + gap: 4px; + min-width: 0; + flex: 1 1 auto; +} + +.chat-share-page__process-inspector-minimized-head { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.chat-share-page__process-inspector-minimized-time { + min-width: 0; +} + +.chat-share-page__process-inspector-minimized-log { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-share-page__process-inspector-minimized-button.ant-btn { + border-radius: 999px; + flex: 0 0 auto; +} + .chat-share-page__room-settings-checkbox-group { display: grid; gap: 10px; @@ -1623,7 +1947,7 @@ top: env(safe-area-inset-top, 0px); left: env(safe-area-inset-left, 0px); z-index: 1605; - width: min(176px, calc(100vw - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px) - 24px)); + width: min(240px, calc(100vw - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px) - 24px)); padding: 8px 8px 10px; border-radius: 18px; background: rgba(15, 23, 42, 0.88); @@ -1760,7 +2084,7 @@ } .chat-share-page__program-minimized { - width: min(164px, calc(100vw - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px) - 24px)); + width: min(220px, calc(100vw - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px) - 24px)); padding: 8px; } diff --git a/src/app/main/pages/ChatSharePage.tsx b/src/app/main/pages/ChatSharePage.tsx index 785320b..fbfd2a8 100644 --- a/src/app/main/pages/ChatSharePage.tsx +++ b/src/app/main/pages/ChatSharePage.tsx @@ -1,4 +1,4 @@ -import { AppstoreOutlined, CheckOutlined, CloseOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons'; +import { AppstoreOutlined, CheckOutlined, CloseOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, MinusOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons'; import { Alert, App, Button, Checkbox, Drawer, Dropdown, Input, Modal, Select, Spin, Tabs, Tag, Typography, type MenuProps } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'; @@ -97,13 +97,8 @@ const SHARE_PROCESSING_CLOCK_INTERVAL_MS = 60 * 1000; const SHARE_TOKEN_USAGE_CLOCK_INTERVAL_MS = 1000; const SHARE_EXPIRY_CLOCK_INTERVAL_MS = 60 * 1000; const SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY = 'codex-live-share-immediate-send-pinned-by-token'; +const SHARE_LAST_ROOM_STORAGE_KEY = 'codex-live-share-last-room-by-token'; const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000; -const SHARE_ROOM_SWIPE_DELETE_LIMIT_PX = 108; -const SHARE_ROOM_SWIPE_DELETE_THRESHOLD_PX = 72; -const SHARE_ROOM_TOUCH_TAP_SLOP_PX = 14; -const SHARE_ROOM_TOUCH_SCROLL_CANCEL_PX = 18; -const SHARE_ROOM_SWIPE_ACTIVATION_PX = 18; -const SHARE_ROOM_SWIPE_START_EDGE_PX = 56; const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [ { value: 'always', label: '매번 묻기', minutes: 0 }, { value: '5', label: '5분 유지', minutes: 5 }, @@ -139,6 +134,13 @@ type ShareProgramTarget = { meta?: string; appId?: string; }; +type ShareMinimizedProgramItem = { + target: ShareProgramTarget; + position: { + x: number; + y: number; + }; +}; type ShareAppEnvironment = PlayAppEnvironment; type ShareSearchResult = { key: string; @@ -167,6 +169,25 @@ type ShareNotificationClientStatus = { summaryLabel: string; tone: ShareNotificationStatusTone; }; +type ShareProcessInspectorMode = 'default' | 'fullscreen' | 'minimized'; +type ShareProcessChecklistStep = { + key: string; + label: string; + status: 'pending' | 'in_progress' | 'completed'; + note: string; +}; +type ShareProcessInspectorPayload = { + requestId: string; + statusTag: { color: string; label: string }; + summary: string; + elapsedLabel: string; + startedAtLabel: string; + updatedAtLabel: string; + activityLines: string[]; + latestActivityLine: string; + checklist: ShareProcessChecklistStep[]; + narratives: string[]; +}; const LazyTextMemoWidget = lazy(async () => { const module = await import('../../../widgets/text-memo-widget'); @@ -310,6 +331,68 @@ function writeStoredShareImmediateSendPinnedByToken(nextMap: Record; + } + + try { + const raw = window.localStorage.getItem(SHARE_LAST_ROOM_STORAGE_KEY); + + if (!raw) { + return {} as Record; + } + + const parsed = JSON.parse(raw) as Record; + + return Object.entries(parsed).reduce>((result, [token, sessionId]) => { + const normalizedToken = String(token ?? '').trim(); + const normalizedSessionId = String(sessionId ?? '').trim(); + + if (normalizedToken && normalizedSessionId) { + result[normalizedToken] = normalizedSessionId; + } + + return result; + }, {}); + } catch { + return {} as Record; + } +} + +function readStoredShareLastRoomSessionId(token: string) { + const normalizedToken = token.trim(); + + if (!normalizedToken) { + return ''; + } + + return readStoredShareLastRoomByToken()[normalizedToken] ?? ''; +} + +function writeStoredShareLastRoomSessionId(token: string, sessionId: string | null) { + if (typeof window === 'undefined') { + return; + } + + const normalizedToken = token.trim(); + + if (!normalizedToken) { + return; + } + + const normalizedSessionId = String(sessionId ?? '').trim(); + const nextMap = readStoredShareLastRoomByToken(); + + if (normalizedSessionId) { + nextMap[normalizedToken] = normalizedSessionId; + } else { + delete nextMap[normalizedToken]; + } + + window.localStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, JSON.stringify(nextMap)); +} + function getClientNotificationPermission(): ClientNotificationPermissionState { if ( typeof window === 'undefined' @@ -626,6 +709,14 @@ const PROGRAM_MINIMIZED_DEFAULT_WIDTH = 176; const PROGRAM_MINIMIZED_DEFAULT_HEIGHT = 58; const SHARE_PROGRAM_MODAL_Z_INDEX = 1750; const SHARE_PROGRAM_MINIMIZED_Z_INDEX = SHARE_PROGRAM_MODAL_Z_INDEX + 5; +const SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING = 12; +const SHARE_PROCESS_INSPECTOR_DEFAULT_WIDTH = 520; +const SHARE_PROCESS_INSPECTOR_DEFAULT_HEIGHT = 540; +const SHARE_PROCESS_INSPECTOR_FULLSCREEN_WIDTH = 1120; +const SHARE_PROCESS_INSPECTOR_FULLSCREEN_HEIGHT = 820; +const SHARE_PROCESS_INSPECTOR_MINIMIZED_WIDTH = 250; +const SHARE_PROCESS_INSPECTOR_MINIMIZED_HEIGHT = 72; +const SHARE_PROCESS_INSPECTOR_Z_INDEX = SHARE_PROGRAM_MINIMIZED_Z_INDEX + 5; const MOBILE_INPUT_VIEWPORT_TOP_PADDING_PX = 6; const MOBILE_INPUT_VIEWPORT_BOTTOM_PADDING_PX = 8; const MOBILE_INPUT_VIEWPORT_SYNC_RETRY_DELAYS_MS = [180, 360] as const; @@ -1285,6 +1376,14 @@ function normalizeSearchKeyword(value: string) { return value.trim().toLocaleLowerCase('ko-KR'); } +function isShareInteractivePointerTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) { + return false; + } + + return Boolean(target.closest('button, a, input, textarea, select, [role="button"], [data-no-drag="true"]')); +} + function formatTokenCount(value: number) { return Math.max(0, Math.round(Number(value) || 0)).toLocaleString('ko-KR'); } @@ -1486,6 +1585,31 @@ function getDefaultProgramMinimizedPosition() { }; } +function getStackedProgramMinimizedPosition(index: number) { + const basePosition = getDefaultProgramMinimizedPosition(); + const offsetX = 18 * Math.max(0, index); + const offsetY = 14 * Math.max(0, index); + + return { + x: Math.max(PROGRAM_MINIMIZED_VIEWPORT_PADDING, basePosition.x - offsetX), + y: Math.max(PROGRAM_MINIMIZED_VIEWPORT_PADDING, basePosition.y - offsetY), + }; +} + +function getDefaultShareProcessInspectorPosition() { + if (typeof window === 'undefined') { + return { x: SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, y: SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING }; + } + + const width = Math.min(SHARE_PROCESS_INSPECTOR_DEFAULT_WIDTH, window.innerWidth - SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING * 2); + const centeredX = Math.round((window.innerWidth - width) / 2); + + return { + x: Math.max(SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, centeredX), + y: Math.max(SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, 88), + }; +} + function matchesSearchKeyword(keyword: string, ...values: Array) { if (!keyword) { return true; @@ -2319,6 +2443,26 @@ function formatShareRuntimeTimestamp(value: string | null | undefined) { }); } +function resolveShareProcessRequestStatusTag(request: ChatConversationRequest) { + switch (request.status) { + case 'accepted': + return { color: 'blue', label: '접수됨' } as const; + case 'queued': + return { color: 'default', label: '대기중' } as const; + case 'started': + return { color: 'processing', label: '처리중' } as const; + case 'completed': + return { color: 'green', label: '완료' } as const; + case 'failed': + return { color: 'red', label: '실패' } as const; + case 'cancelled': + return { color: 'gold', label: '취소됨' } as const; + case 'removed': + default: + return { color: 'default', label: '대기취소' } as const; + } +} + function resolveShareRuntimeTerminalTag(terminalStatus: ChatRuntimeTerminalStatus) { switch (terminalStatus) { case 'cancelled': @@ -2341,6 +2485,112 @@ function resolveShareRuntimeStatusTag(item: ChatRuntimeJobItem) { return { color: 'default', label: '대기중' } as const; } +function matchesShareProcessKeywords(lines: string[], pattern: RegExp) { + return lines.some((line) => pattern.test(line.toLowerCase())); +} + +function resolveShareProcessChecklistStepStatus( + request: ChatConversationRequest, + enabled: boolean, + completed: boolean, +): ShareProcessChecklistStep['status'] { + if (completed) { + return 'completed'; + } + + if (enabled || request.status === 'started') { + return 'in_progress'; + } + + return 'pending'; +} + +function buildShareProcessInspectorPayload( + request: ChatConversationRequest, + activityLines: string[], + nowMs: number, + runtimeItem?: ChatRuntimeJobItem | (ChatRuntimeJobItem & { terminalStatus?: ChatRuntimeTerminalStatus; lastUpdatedAt?: string }) | null, +): ShareProcessInspectorPayload { + const normalizedLines = activityLines.map((line) => line.trim()).filter(Boolean); + const startedAt = runtimeItem?.startedAt ?? runtimeItem?.enqueuedAt ?? request.updatedAt?.trim() ?? request.createdAt; + const updatedAt = 'lastUpdatedAt' in (runtimeItem ?? {}) ? runtimeItem?.lastUpdatedAt ?? request.updatedAt : request.updatedAt; + const latestActivityLine = normalizedLines[normalizedLines.length - 1] ?? ''; + const hasAnalysis = matchesShareProcessKeywords(normalizedLines, /(read|search|inspect|context|analysis|analy|조사|확인|정리|검토|조회)/); + const hasImplementation = matchesShareProcessKeywords(normalizedLines, /(edit|patch|write|implement|fix|update|modify|반영|수정|작성)/); + const hasVerification = matchesShareProcessKeywords(normalizedLines, /(test|build|verify|check|validate|검증|점검|실행)/); + const isTerminal = ['completed', 'failed', 'cancelled', 'removed'].includes(request.status); + const answerSummary = summarizeShareReplyReferenceText( + request.responseText || request.statusMessage || latestActivityLine || '아직 기록된 응답이 없습니다.', + 120, + ); + const summary = summarizeShareReplyReferenceText(request.userText || runtimeItem?.summary || '요약 정보 없음', 140); + const analysisStatus = resolveShareProcessChecklistStepStatus(request, hasAnalysis, hasAnalysis && (hasImplementation || hasVerification || isTerminal)); + const implementationStatus = resolveShareProcessChecklistStepStatus( + request, + hasImplementation, + (hasImplementation && (hasVerification || isTerminal)) || request.hasResponse, + ); + const verificationStatus = resolveShareProcessChecklistStepStatus( + request, + hasVerification || request.hasResponse, + request.status === 'completed' || request.hasResponse, + ); + const currentStepLabel = + verificationStatus === 'in_progress' + ? '검증/정리' + : implementationStatus === 'in_progress' + ? '응답 작성' + : analysisStatus === 'in_progress' + ? '요청 분석' + : request.status === 'queued' + ? '대기' + : request.status === 'completed' + ? '완료' + : resolveShareProcessRequestStatusTag(request).label; + + return { + requestId: request.requestId, + statusTag: resolveShareProcessRequestStatusTag(request), + summary, + elapsedLabel: formatElapsedDuration(startedAt, nowMs) || '-', + startedAtLabel: formatShareRuntimeTimestamp(startedAt), + updatedAtLabel: formatShareRuntimeTimestamp(updatedAt), + activityLines: normalizedLines, + latestActivityLine, + checklist: [ + { + key: 'accepted', + label: '요청 접수', + status: 'completed', + note: `${formatShareRuntimeTimestamp(request.createdAt)} 접수`, + }, + { + key: 'analysis', + label: '계획·문맥 확인', + status: analysisStatus, + note: hasAnalysis ? '질문과 문맥, 관련 리소스를 확인 중입니다.' : '활동 로그 대기', + }, + { + key: 'implementation', + label: '실행·응답 작성', + status: implementationStatus, + note: hasImplementation ? '수정/실행/응답 초안을 진행 중입니다.' : '아직 실행 단계에 도달하지 않았습니다.', + }, + { + key: 'verification', + label: '검증·결과 정리', + status: verificationStatus, + note: request.status === 'completed' ? '최종 응답 또는 결과 정리가 끝났습니다.' : hasVerification ? '검증과 결과 정리를 진행 중입니다.' : '검증 단계 대기', + }, + ], + narratives: [ + `현재 단계는 ${currentStepLabel}입니다.`, + latestActivityLine ? `최근 실행 설명: ${latestActivityLine}` : '최근 실행 설명이 아직 기록되지 않았습니다.', + request.hasResponse ? `현재 응답 요약: ${answerSummary}` : '아직 최종 응답은 기록되지 않았습니다.', + ], + }; +} + async function createSharePreviewFetchError(response: Response): Promise { const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; let responseMessage = ''; @@ -2957,6 +3207,7 @@ function ShareRequestCard({ isActiveRequestCancellationSaving = false, onResubmitRequestDirect, isDirectResubmitSaving = false, + onOpenProcessInspector, }: { request: ChatConversationRequest; requestById: Map; @@ -2992,6 +3243,7 @@ function ShareRequestCard({ isActiveRequestCancellationSaving?: boolean; onResubmitRequestDirect?: ((requestId: string) => Promise) | null; isDirectResubmitSaving?: boolean; + onOpenProcessInspector?: ((requestId: string) => void) | null; }) { const questionText = useMemo(() => buildShareVisibleText(request.userText), [request.userText]); const questionPreviewItems = useMemo( @@ -3115,6 +3367,19 @@ function ShareRequestCard({ }} /> ) : null} + {isRequestStillRunning && onOpenProcessInspector ? ( + @@ -4788,11 +5045,13 @@ export function ChatSharePage() { className="chat-share-page__program-minimized-icon chat-share-page__program-minimized-close" icon={} aria-label="프로그램 닫기" - onClick={handleCloseProgram} + onClick={() => { + handleCloseMinimizedProgram(item.target.key); + }} /> - ) : null; + )); const syncScrollJumpVisibility = useCallback(() => { const scrollContainer = scrollContainerRef.current; @@ -5014,6 +5273,17 @@ export function ChatSharePage() { void refreshSnapshot({ initialLoad: true }); return undefined; }, [normalizedToken, refreshSnapshot]); + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const urlRoomSessionId = new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || ''; + const restoredRoomSessionId = urlRoomSessionId || readStoredShareLastRoomSessionId(normalizedToken); + + requestedRoomSessionIdRef.current = restoredRoomSessionId; + setRequestedRoomSessionId(restoredRoomSessionId); + }, [normalizedToken]); useEffect(() => { if (!normalizedToken || !hasSnapshotRef.current) { @@ -5037,19 +5307,27 @@ export function ChatSharePage() { return; } + writeStoredShareLastRoomSessionId(normalizedToken, null); setRequestedRoomSessionId(''); - }, [requestedRoomSessionId, shareRooms]); + }, [normalizedToken, requestedRoomSessionId, shareRooms]); useEffect(() => { - if (isDeletingRoom) { + if (!normalizedToken) { return; } - if (!shareRooms.some((room) => room.sessionId === swipedRoomSessionId)) { - setSwipedRoomSessionId(''); - resetRoomSwipeState(); - } - }, [isDeletingRoom, resetRoomSwipeState, shareRooms, swipedRoomSessionId]); + const persistedRoomSessionId = selectedShareRoomSessionId.trim(); + if (!persistedRoomSessionId) { + writeStoredShareLastRoomSessionId(normalizedToken, null); + return; + } + + if (!shareRooms.some((room) => room.sessionId === persistedRoomSessionId)) { + return; + } + + writeStoredShareLastRoomSessionId(normalizedToken, persistedRoomSessionId); + }, [normalizedToken, selectedShareRoomSessionId, shareRooms]); useEffect(() => { if (typeof window === 'undefined') { return; @@ -5534,30 +5812,13 @@ export function ChatSharePage() { } }; - const handleSelectShareRoom = useCallback((sessionId: string, options?: { bypassSuppression?: boolean }) => { + const handleSelectShareRoom = useCallback((sessionId: string) => { const normalizedSessionId = sessionId.trim(); - const shouldBypassSuppression = options?.bypassSuppression === true; - - if (!shouldBypassSuppression && skipNextRoomClickSessionIdRef.current === normalizedSessionId) { - skipNextRoomClickSessionIdRef.current = ''; - return; - } - - if (!shouldBypassSuppression && (suppressRoomClickRef.current || roomSwipeMovedRef.current)) { - suppressRoomClickRef.current = false; - return; - } if (!normalizedSessionId) { return; } - if (swipedRoomSessionId === normalizedSessionId || draggingRoomSessionId === normalizedSessionId) { - setSwipedRoomSessionId(''); - resetRoomSwipeState(); - return; - } - if (normalizedSessionId === selectedShareRoomSessionId) { setIsShareRoomListVisible(false); return; @@ -5567,7 +5828,7 @@ export function ChatSharePage() { setIsRoomSwitching(true); setRequestedRoomSessionId(normalizedSessionId); setIsShareRoomListVisible(false); - }, [draggingRoomSessionId, resetRoomSwipeState, selectedShareRoomSessionId, swipedRoomSessionId]); + }, [selectedShareRoomSessionId]); const handleCreateShareRoom = useCallback(async () => { if (!normalizedToken || isCreatingRoom) { @@ -6370,6 +6631,295 @@ export function ChatSharePage() { () => new Map((snapshot?.activityLogs ?? []).map((item) => [item.requestId.trim(), item])), [snapshot?.activityLogs], ); + const activeProcessInspectorRequest = useMemo( + () => (activeProcessInspectorRequestId.trim() ? requestById.get(activeProcessInspectorRequestId.trim()) ?? null : null), + [activeProcessInspectorRequestId, requestById], + ); + const activeProcessInspectorPayload = useMemo(() => { + if (!activeProcessInspectorRequest) { + return null; + } + + const runtimeItem = shareRuntimeItemByRequestId.get(activeProcessInspectorRequest.requestId.trim()) ?? null; + const activityLines = activityLogByRequestId.get(activeProcessInspectorRequest.requestId.trim())?.lines ?? []; + return buildShareProcessInspectorPayload(activeProcessInspectorRequest, activityLines, nowMs, runtimeItem); + }, [activeProcessInspectorRequest, activityLogByRequestId, nowMs, shareRuntimeItemByRequestId]); + const hasActiveProcessInspector = activeProcessInspectorRequestId.trim().length > 0 && activeProcessInspectorPayload != null; + useEffect(() => { + if (!hasActiveProcessInspector) { + processInspectorDragStateRef.current = null; + return; + } + + const clampPosition = (position: { x: number; y: number }) => { + const cardWidth = processInspectorCardRef.current?.offsetWidth ?? SHARE_PROCESS_INSPECTOR_DEFAULT_WIDTH; + const cardHeight = processInspectorCardRef.current?.offsetHeight ?? SHARE_PROCESS_INSPECTOR_DEFAULT_HEIGHT; + const maxX = Math.max( + SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, + window.innerWidth - cardWidth - SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, + ); + const maxY = Math.max( + SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, + window.innerHeight - cardHeight - SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, + ); + + return { + x: clampProgramMinimizedValue(position.x, SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, maxX), + y: clampProgramMinimizedValue(position.y, SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, maxY), + }; + }; + + const syncPosition = (position: { x: number; y: number }) => { + const nextPosition = clampPosition(position); + processInspectorPositionRef.current = nextPosition; + setProcessInspectorPosition(nextPosition); + }; + + const handleResize = () => { + syncPosition(processInspectorPositionRef.current); + }; + + const handlePointerMove = (event: PointerEvent) => { + const dragState = processInspectorDragStateRef.current; + + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + const deltaX = event.clientX - dragState.lastX; + const deltaY = event.clientY - dragState.lastY; + dragState.lastX = event.clientX; + dragState.lastY = event.clientY; + + syncPosition({ + x: processInspectorPositionRef.current.x + deltaX, + y: processInspectorPositionRef.current.y + deltaY, + }); + }; + + const finishPointerDrag = (event: PointerEvent) => { + const dragState = processInspectorDragStateRef.current; + + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + if (dragState.captureTarget.hasPointerCapture(event.pointerId)) { + dragState.captureTarget.releasePointerCapture(event.pointerId); + } + + processInspectorDragStateRef.current = null; + }; + + window.addEventListener('resize', handleResize); + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', finishPointerDrag); + window.addEventListener('pointercancel', finishPointerDrag); + handleResize(); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', finishPointerDrag); + window.removeEventListener('pointercancel', finishPointerDrag); + }; + }, [activeProcessInspectorRequestId, hasActiveProcessInspector, processInspectorMode]); + const processInspectorCard = activeProcessInspectorPayload ? ( +
+
+
+
+
+
+
+ {processInspectorMode === 'minimized' ? ( +
+
+
+ {activeProcessInspectorPayload.statusTag.label} + {activeProcessInspectorPayload.elapsedLabel} +
+ + {activeProcessInspectorPayload.latestActivityLine || '활동 로그 대기 중'} + +
+ +
+ ) : ( +
+
+
+
+ {activeProcessInspectorPayload.statusTag.label} + {`처리 시간 ${activeProcessInspectorPayload.elapsedLabel}`} +
+ +
+ {isProcessInspectorSummaryCollapsed ? null : ( +
+
+ 요청 + {activeProcessInspectorPayload.summary} +
+
+ ID + + {activeProcessInspectorPayload.requestId} + +
+
+ 시작 + {activeProcessInspectorPayload.startedAtLabel} +
+
+ 업데이트 + {activeProcessInspectorPayload.updatedAtLabel} +
+
+ 최근 로그 + {activeProcessInspectorPayload.latestActivityLine || '아직 활동 로그가 없습니다.'} +
+
+ )} +
+
+
+
+ 계획 체크리스트 +
+
+ {activeProcessInspectorPayload.checklist.map((step) => ( +
+ {step.label} + + {step.status === 'completed' ? '완료' : step.status === 'in_progress' ? '진행중' : '대기'} + + {step.note} +
+ ))} +
+
+
+
+ 추가 실행 설명 + +
+ {isProcessInspectorNarrativesCollapsed ? null : ( +
+ {activeProcessInspectorPayload.narratives.map((item, index) => ( +
+ {String(index + 1).padStart(2, '0')} + {item} +
+ ))} +
+ )} +
+
+
+ 활동 로그 + {activeProcessInspectorPayload.activityLines.length}줄 +
+
+ {activeProcessInspectorPayload.activityLines.length > 0 ? ( + activeProcessInspectorPayload.activityLines.map((line, index) => ( +
+ {String(index + 1).padStart(2, '0')} + {line} +
+ )) + ) : ( + 활동 로그가 아직 기록되지 않았습니다. + )} +
+
+
+
+ )} +
+ ) : null; const shareChatTypeLabel = useMemo(() => { const candidates = [ currentRequest?.chatTypeLabel, @@ -6403,6 +6953,18 @@ export function ChatSharePage() { [aggregateStatusTag?.elapsedLabel, pendingProcessingCount, pendingUnansweredCount], ); + useEffect(() => { + if (!activeProcessInspectorRequestId.trim()) { + return; + } + + if (activeProcessInspectorRequest) { + return; + } + + setActiveProcessInspectorRequestId(''); + }, [activeProcessInspectorRequest, activeProcessInspectorRequestId]); + useEffect(() => { if (!replyReferenceRequestId.trim()) { return; @@ -6506,9 +7068,8 @@ export function ChatSharePage() { recordShareAppLaunch(target.appId); setProgramReloadKey(0); + setMinimizedPrograms((current) => current.filter((item) => item.target.key !== target.key)); setProgramTarget(target); - setProgramMinimizedTarget(target); - setIsProgramMinimized(false); }, [canLaunchShareProgram, message, recordShareAppLaunch]); const openAllowedPlayAppEnvironment = useCallback((entry: PlayAppEntry, environment: ShareAppEnvironment) => { if (!shareAllowedAppIdSet.has(entry.id)) { @@ -6527,11 +7088,25 @@ export function ChatSharePage() { openProgramTarget(buildPlayAppEnvironmentTarget(entry.id, entry.name, environment)); }, [message, openProgramTarget, shareAllowedAppIdSet]); const handleMinimizeProgram = useCallback(() => { - if (programTarget) { - setProgramMinimizedTarget(programTarget); + if (!programTarget) { + return; } - setIsProgramMinimized(true); + setMinimizedPrograms((current) => { + const existingIndex = current.findIndex((item) => item.target.key === programTarget.key); + const nextPosition = existingIndex >= 0 ? current[existingIndex].position : getStackedProgramMinimizedPosition(current.length); + const nextItem: ShareMinimizedProgramItem = { + target: programTarget, + position: nextPosition, + }; + + if (existingIndex >= 0) { + return current.map((item, index) => (index === existingIndex ? nextItem : item)); + } + + return [...current, nextItem]; + }); + setProgramTarget(null); }, [programTarget]); const handleSearchResultSelect = useCallback((result: ShareSearchResult) => { if (result.appEntry) { @@ -6590,8 +7165,6 @@ export function ChatSharePage() { }, [openAllowedPlayAppEnvironment, openProgramTarget, selectedAppEnvironment]); const closeProgramTarget = useCallback(() => { setProgramTarget(null); - setProgramMinimizedTarget(null); - setIsProgramMinimized(false); }, []); const sharedServerCommandAccess = useMemo( () => ({ @@ -7538,6 +8111,7 @@ export function ChatSharePage() { onSetRequestAnchor={setRequestAnchorRef} onSetResponseAnchor={setResponseAnchorRef} onSetPromptAnchor={setPromptAnchorRef} + onOpenProcessInspector={openProcessInspector} /> ))} @@ -7581,11 +8155,7 @@ export function ChatSharePage() { return (
{canDeleteRoom ? (