Files
connectai/src/sidebarProvider.ts
T

5048 lines
243 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
_getBrainDir,
findBrainFiles,
buildApiUrl,
getActiveBrainProfile,
getBrainProfiles,
logError,
logInfo,
resolveEngine,
summarizeText,
openInEditorGroup
} from './utils';
import { getConfig } from './config';
import { AgentExecutor, ChatMessage } from './agent';
import { BridgeInterface } from './bridge';
import { buildProjectChronicleGuardContext, ProjectChronicleManager, ProjectProfile } from './features/projectChronicle';
import type { ModelLifecycleManager } from './lmstudio/lifecycleManager';
import type { IActivityTracker } from './lmstudio/activityTracker';
import { handleChatMessage } from './sidebar/chatHandlers';
import { handleBrainMessage } from './sidebar/brainHandlers';
import { handleChronicleMessage } from './sidebar/chronicleHandlers';
import { handleAgentMessage } from './sidebar/agentHandlers';
import { getOrCreateAgentEntry, resolveScopeForAgent } from './skills/agentKnowledgeMap';
import { estimateModelParamsB } from './lib/contextManager';
import { loadExternalSkills, formatSkillsAsPromptBlock } from './skills/externalSkillLoader';
import {
buildOrRefreshArchitectureDoc,
architectureDocPathFor,
formatArchitectureContextForPrompt,
resolveActiveSubprojectRoot,
scanProject,
} from './features/projectArchitecture';
import { detectProjectIntent, KnownProject } from './features/projectArchitecture/intentDetector';
import {
readCompanyState,
runCompanyTurn,
resumeCompanyTurn,
listResumableSessions,
summarizeForChip,
CompanyTurnEvent,
DispatcherDeps,
ApprovalDecision,
COMPANY_AGENTS,
COMPANY_AGENT_ORDER,
ROLE_CATEGORY_LABELS,
ROLE_CATEGORY_ORDER,
resolveAgent,
PIPELINE_TEMPLATES,
} from './features/company';
import { AIService } from './core/services';
export interface SidebarLmStudioDeps {
lifecycle: ModelLifecycleManager;
activity: IActivityTracker;
/** Returns the list of model identifiers currently loaded in LM Studio (cached). */
loadedModels: () => Promise<string[]>;
}
interface LastVisibleChatSnapshot {
history: ChatMessage[];
brainProfileId: string;
sessionId: string | null;
timestamp: number;
negativePrompt?: string;
}
interface ChatSession {
id: string;
title: string;
timestamp: number;
history: ChatMessage[];
brainProfileId: string;
negativePrompt?: string;
}
/**
* Sidebar UI Provider implementing BridgeInterface for BridgeServer
*/
export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeInterface {
public static readonly viewType = 'g1nation-v2-view';
static readonly activeSessionStateKey = 'g1nation.activeSessionId';
static readonly lastVisibleChatStateKey = 'g1nation.lastVisibleChat';
static readonly blankChatStateKey = 'g1nation.blankChatActive';
static readonly lastAgentStateKey = 'g1nation.lastAgentPath';
static readonly chronicleProjectsStateKey = 'g1nation.chronicleProjects';
static readonly activeChronicleProjectStateKey = 'g1nation.activeChronicleProjectId';
static readonly lastAutoChronicleSignatureStateKey = 'g1nation.lastAutoChronicleSignature';
_view?: vscode.WebviewView;
_panel?: vscode.WebviewPanel;
/**
* Pixel Office "전체보기" — editor area에 띄운 별도 webview panel.
* 사이드바 mini 패널과 같은 `pixelOfficeUpdate` 메시지 스트림을 받고
* 더 큰 화면에서 직군별 캐릭터들을 사무실 배경 위에 배치해 보여준다.
* 닫혀 있으면 undefined — broadcast 시 안전하게 skip.
*/
private _pixelOfficePanel?: vscode.WebviewPanel;
public brainEnabled = true;
_currentSessionBrainId: string | null = null;
_currentNegativePrompt: string = '';
readonly _chronicle = new ProjectChronicleManager();
_modelDiscoveryInFlight = false;
_modelsCache: { url: string; models: string[]; online: boolean; fetchedAt: number } | null = null;
static readonly MODELS_CACHE_TTL_MS = 30000;
/**
* AbortController for the currently-running 1인 기업 turn. Cleared when
* the turn ends (success or fail). The webview's Stop button routes
* through `stopGeneration`, which calls `abortCompanyTurn()` to flip this
* — the dispatcher's `signal` then short-circuits between phases.
*/
private _companyAbort?: AbortController;
/**
* Open approval gates. The dispatcher emits `phase: 'awaiting-approval'`
* for stages with `requiresApproval`, and waits on a Promise this map
* stores. The webview surfaces 승인 / 수정요청 / 중단 buttons; clicks
* route through `chatHandlers.respondCompanyApproval` which calls
* `resolveApprovalGate(stageId, decision)` here.
*
* Keyed by stageId — only one approval may be pending per stage at a
* time (sequential dispatch), but multiple stages across the same turn
* each get their own entry as they hit their gate. On turn abort we
* resolve all outstanding entries with `{ kind: 'abort' }` so the
* dispatcher unblocks cleanly.
*/
private _pendingApprovals = new Map<string, (d: import('./features/company/dispatcher').ApprovalDecision) => void>();
/**
* Snapshot of the last completed company turn — fed into the intent
* classifier so it can distinguish "follow-up on previous round" from
* "brand-new task". Reset to undefined when the chat is cleared / new
* session loaded. Only completed (non-aborted) reports populate this.
*/
private _lastCompanyTurnSummary?: {
brief: string;
reportTail: string;
finishedAt: number;
};
/**
* Intent Alignment 진행 중인 상태. new_task가 분류되면 분석기를 돌리고,
* confidence가 충분치 않으면 사용자에게 질문 카드를 띄운 뒤 이 슬롯에
* 현재 contract를 보관 → 다음 사용자 메시지를 *답변*으로 해석한다.
*
* 한 번에 한 alignment만 진행. 회사 모드는 sequential 방식이라 동시
* 다발 alignment가 생길 수 없음. 사용자가 도중 다른 chat을 던지면
* 그 메시지는 답변으로 합쳐진다 — "취소" 버튼이나 모드 토글로만 빠져
* 나갈 수 있게 하는 게 흐름상 안전.
*/
private _pendingAlignment?: {
userOriginalPrompt: string;
contract: import('./features/company').RequirementContract;
/** 이번 alignment 동안 사용자가 답한 누적 라운드 수 — 무한 라운드 방지. */
roundsAsked: number;
/** 이번 turn 한정 pipeline override (Phase 4 분류기에서 전달된 추천). */
pipelineIdOverride?: string;
};
/**
* Pixel Office 현재 작업 상태 캐시. 모든 emit/alignment hook이 한 슬롯에
* 모인 상태를 patch 형식으로 갱신 → broadcast. UI layer이므로 어떤 판단
* 로직에도 다시 영향 주지 않는다 — 단방향 read-only 흐름.
*/
private _pixelOfficeState?: import('./features/company').AgentWorkState;
/** 같은 말풍선 텍스트 연달아 안 나오게 추적용 (상태/이벤트 별). */
private _pixelOfficeLastBubble: Map<string, string> = new Map();
/**
* Astra Office 사용자 정의 레이아웃. workspace state에 저장되어 프로젝트별
* 다른 배치를 가질 수 있다. null/undefined면 webview가 디폴트 LAYOUT을 사용.
* 편집 모드에서 사용자가 드래그로 위치 조정 후 저장 → 이 슬롯 업데이트 →
* webview에 다시 broadcast.
*
* 데이터 shape:
* {
* cells: [{ roleKey, deskX, deskY, charX, charY }, ...],
* decos: [{ id, type, x, y }, ...]
* }
*/
static readonly pixelOfficeLayoutKey = 'g1nation.pixelOfficeLayout';
private _readPixelOfficeLayout(): unknown {
return this._context.workspaceState.get(SidebarChatProvider.pixelOfficeLayoutKey);
}
private async _writePixelOfficeLayout(layout: unknown): Promise<void> {
await this._context.workspaceState.update(SidebarChatProvider.pixelOfficeLayoutKey, layout);
}
private async _clearPixelOfficeLayout(): Promise<void> {
await this._context.workspaceState.update(SidebarChatProvider.pixelOfficeLayoutKey, undefined);
}
/** Phase B-2에서 chatHandlers가 alignment 진행 여부를 빠르게 확인하는 용도. */
isAlignmentPending(): boolean {
return !!this._pendingAlignment;
}
// ─────────────────────── Pixel Office collector ───────────────────────
//
// 이 섹션은 *기존 emit / alignment / classifier 결과를 가로채* 그대로
// 모니터링용 상태로 변환하는 단방향 hub. 어떤 메서드도 추가 LLM 콜을
// 만들지 않고, dispatcher / planner / chatHandlers의 어느 분기에도
// 영향을 주지 않는다. 그래서 이 섹션 전체를 통째로 지워도 회사 모드는
// 평소와 동일하게 동작해야 한다 — 그 invariant가 깨지면 위반이다.
/** webview로 보낼 직전 patch와 가벼운 reset/유틸. */
private _pixelOfficeBroadcast(patch: Partial<import('./features/company').AgentWorkState>, opts?: {
bubbleStatus?: import('./features/company').AgentStatus;
bubbleEvent?: import('./features/company').AgentEvent;
bubbleAgentId?: string;
}): void {
const cfg = getConfig();
if (!cfg.companyPixelOfficeEnabled) return;
const prev = this._pixelOfficeState ?? {
agentId: 'main',
agentName: 'Agent',
status: 'idle' as import('./features/company').AgentStatus,
recentLogs: [],
updatedAt: Date.now(),
};
const next: import('./features/company').AgentWorkState = {
...prev,
...patch,
recentLogs: patch.recentLogs ?? prev.recentLogs ?? [],
updatedAt: Date.now(),
};
this._pixelOfficeState = next;
// 말풍선 — 상태 또는 이벤트가 지정된 경우만 생성. 풀에서 직전에 쓴 텍스트
// 회피해서 같은 말 연달아 안 나오게.
const bubbles: import('./features/company').AgentBubble[] = [];
if (cfg.companyPixelOfficeBubbles) {
const aid = opts?.bubbleAgentId ?? next.agentId;
if (opts?.bubbleStatus) {
const lastKey = `status:${opts.bubbleStatus}`;
// 동적 import 대신 require로 — 메서드 내부에서 너무 늦게 await 걸지 않게.
const { getStatusBubbleText, makeBubble } = require('./features/company') as typeof import('./features/company');
const text = getStatusBubbleText(opts.bubbleStatus, this._pixelOfficeLastBubble.get(lastKey));
if (text) {
this._pixelOfficeLastBubble.set(lastKey, text);
bubbles.push(makeBubble({ agentId: aid, text, type: 'status' }));
}
}
if (opts?.bubbleEvent) {
const lastKey = `event:${opts.bubbleEvent}`;
const { getEventBubbleText, eventBubbleType, makeBubble } = require('./features/company') as typeof import('./features/company');
const text = getEventBubbleText(opts.bubbleEvent, this._pixelOfficeLastBubble.get(lastKey));
if (text) {
this._pixelOfficeLastBubble.set(lastKey, text);
bubbles.push(makeBubble({ agentId: aid, text, type: eventBubbleType(opts.bubbleEvent) }));
}
}
}
const payload = {
type: 'pixelOfficeUpdate' as const,
value: {
state: next,
bubbles,
config: {
enabled: cfg.companyPixelOfficeEnabled,
bubblesEnabled: cfg.companyPixelOfficeBubbles,
maxVisibleBubbles: 3,
bubbleDurationMs: 4500,
},
},
};
// 사이드바 mini panel + 전체보기 webview panel 둘 다 같은 데이터 받음.
this._view?.webview.postMessage(payload);
this._pixelOfficePanel?.webview.postMessage(payload);
}
/** recentLogs ring buffer push — webview에서 보여주는 "최근 로그". */
private _pixelOfficeAppendLog(line: string): string[] {
const cur = this._pixelOfficeState?.recentLogs ?? [];
const next = [...cur, line].slice(-6);
return next;
}
/** Intent classifier가 분류 직후 호출되어 한 줄 상태 진입을 표시. */
pixelOfficeOnIntentClassified(intent: 'chat' | 'followup' | 'new_task', userPrompt: string): void {
if (intent !== 'new_task') {
// 잡담/후속 — 회사 모드 표면적으로 일 안 하므로 'idle'로 잠시 복귀.
this._pixelOfficeBroadcast({
status: 'idle',
currentTask: userPrompt,
currentStep: intent === 'followup' ? '직전 라운드 후속 응답' : '잡담 응답',
message: undefined,
recentLogs: this._pixelOfficeAppendLog(`💬 분류: ${intent}`),
}, { bubbleStatus: 'idle' });
return;
}
// new_task — intake → analyzing 흐름의 첫 신호.
this._pixelOfficeBroadcast({
status: 'intake',
currentTask: userPrompt,
currentStep: '요청 접수',
recentLogs: this._pixelOfficeAppendLog(`📨 새 작업 요청 접수`),
}, { bubbleStatus: 'intake' });
}
/** Intent Alignment 분석 시작 (LLM 콜 직전). */
pixelOfficeOnAlignmentStart(userPrompt: string): void {
this._pixelOfficeBroadcast({
status: 'analyzing',
currentTask: this._pixelOfficeState?.currentTask || userPrompt,
currentStep: '요청 분석 중 (C·G·C·F 추출)',
message: undefined,
recentLogs: this._pixelOfficeAppendLog('🔍 의도 분석 시작'),
}, { bubbleStatus: 'analyzing' });
}
/** Alignment 결과를 받아 카드로 표시 직전. kind=auto-proceed면 곧장 planning 흐름. */
pixelOfficeOnAlignmentResult(kind: 'questions' | 'confirm' | 'auto-proceed', contract: import('./features/company').RequirementContract): void {
if (kind === 'auto-proceed') {
this._pixelOfficeBroadcast({
status: 'contract_ready',
currentStep: 'Requirement Contract 확정 (자동 진행)',
requirementContract: this._summariseContract(contract),
needUserInput: undefined,
recentLogs: this._pixelOfficeAppendLog('✅ 계약 자동 확정'),
}, {
bubbleStatus: 'contract_ready',
bubbleEvent: 'requirement_contract_created',
});
return;
}
if (kind === 'questions') {
this._pixelOfficeBroadcast({
status: 'need_clarification',
currentStep: '확인 질문 대기 중',
requirementContract: this._summariseContract(contract),
needUserInput: contract.openQuestions,
recentLogs: this._pixelOfficeAppendLog(`🤔 ${contract.openQuestions.length}개 질문 대기`),
}, {
bubbleStatus: 'need_clarification',
bubbleEvent: 'clarification_needed',
});
return;
}
// confirm — 질문 없음, 사용자 OK만 받으면 됨.
this._pixelOfficeBroadcast({
status: 'waiting_approval',
currentStep: '계약 확인 대기',
requirementContract: this._summariseContract(contract),
needUserInput: undefined,
awaitingApproval: '사용자 확인 — 그대로 진행할지 여부',
recentLogs: this._pixelOfficeAppendLog('🧭 계약 확인 카드 표시'),
}, { bubbleStatus: 'waiting_approval' });
}
/** Alignment 취소(사용자가 카드 닫음). */
pixelOfficeOnAlignmentCancelled(): void {
this._pixelOfficeBroadcast({
status: 'idle',
currentStep: '취소됨',
needUserInput: undefined,
awaitingApproval: undefined,
recentLogs: this._pixelOfficeAppendLog('🛑 작업 취소'),
});
}
private _summariseContract(c: import('./features/company').RequirementContract) {
return {
goal: c.goal || undefined,
context: c.context || undefined,
criteria: c.criteria,
format: c.format || undefined,
openQuestions: c.openQuestions,
confidence: c.confidence,
};
}
/**
* Dispatcher의 `CompanyTurnEvent`를 그대로 받아 Pixel Office 상태로 변환.
* `_runCompanyTurn`의 emit 콜백이 매 이벤트를 한 번 더 이쪽으로 흘려준다.
* 함수 안에서 어떤 dispatcher 분기도 다시 트리거하지 않는다 — read-only.
*/
pixelOfficeOnTurnEvent(ev: import('./features/company').CompanyTurnEvent): void {
switch (ev.phase) {
case 'plan-start':
this._pixelOfficeBroadcast({
status: 'planning',
currentStep: '계획 수립',
recentLogs: this._pixelOfficeAppendLog('📋 plan 작성'),
}, { bubbleStatus: 'planning' });
return;
case 'plan-ready': {
// B. 미니 맵용 stage 리스트 초기화. 모두 pending.
const stages = (ev.plan?.tasks ?? []).map((t) => ({
label: (t.task || '').slice(0, 24) || '단계',
agent: t.agent,
status: 'pending' as const,
}));
this._pixelOfficeBroadcast({
status: 'planning',
currentStep: '계획 완료',
nextStep: ev.plan?.tasks?.[0]?.task,
message: ev.plan?.brief?.slice(0, 120),
recentLogs: this._pixelOfficeAppendLog(`📋 plan 완료 (${ev.plan?.tasks?.length ?? 0}개 task)`),
pipelineStages: stages,
}, { bubbleEvent: 'plan_completed' });
return;
}
case 'agent-start': {
// B. 현재 stage를 active로, 이전 stage들은 done으로.
const prev = this._pixelOfficeState?.pipelineStages;
const stages = prev ? prev.map((s, i) => ({
...s,
status: (i < ev.index ? 'done' : i === ev.index ? 'active' : 'pending') as 'done'|'active'|'pending',
})) : undefined;
this._pixelOfficeBroadcast({
status: 'executing',
currentStep: ev.task?.slice(0, 80),
nextStep: undefined,
message: `${ev.agentId}${ev.index + 1}/${ev.total}`,
progress: ev.total > 0 ? ev.index / ev.total : undefined,
recentLogs: this._pixelOfficeAppendLog(`${ev.agentId} start (${ev.index + 1}/${ev.total})`),
pipelineStages: stages,
}, {
bubbleAgentId: ev.agentId,
bubbleStatus: 'executing',
bubbleEvent: ev.index === 0 ? 'execution_started' : undefined,
});
return;
}
case 'agent-done':
// E. action-tag report가 있으면 ticker로 별도 push — 캐릭터가 *무슨
// 일을 했는지* 실시간 가시화. 사용자가 신뢰도/투명성을 즉시 체감.
if (Array.isArray(ev.output?.actionReport) && ev.output.actionReport.length > 0) {
const items = ev.output.actionReport.map((line: string) => ({
agentId: ev.agentId, text: line, ts: Date.now(),
}));
this._view?.webview.postMessage({ type: 'pixelOfficeActivity', value: { items } });
this._pixelOfficePanel?.webview.postMessage({ type: 'pixelOfficeActivity', value: { items } });
}
this._pixelOfficeBroadcast({
status: ev.output?.error ? 'error' : 'executing',
progress: ev.total > 0 ? (ev.index + 1) / ev.total : undefined,
recentLogs: this._pixelOfficeAppendLog(
ev.output?.error
? `${ev.agentId} ${ev.output.error}`
: `${ev.agentId} 완료`,
),
}, {
bubbleAgentId: ev.agentId,
bubbleEvent: ev.output?.error ? 'error_occurred' : undefined,
});
return;
case 'stage-loop':
this._pixelOfficeBroadcast({
status: 'executing',
currentStep: `재시도: ${ev.from}${ev.to} (${ev.iteration}회)`,
recentLogs: this._pixelOfficeAppendLog(`🔁 loop ${ev.from}${ev.to} #${ev.iteration}`),
}, { bubbleEvent: 'stage_loop_retry' });
return;
case 'review-start':
this._pixelOfficeBroadcast({
status: 'reviewing',
currentStep: `검수 사이클 — ${ev.stageLabel}`,
message: `검수자: ${ev.inspectorAgentId} · 최대 ${ev.maxRounds}라운드`,
recentLogs: this._pixelOfficeAppendLog(`🔍 검수 시작: ${ev.stageLabel}`),
}, { bubbleStatus: 'reviewing' });
return;
case 'review-round':
this._pixelOfficeBroadcast({
status: 'reviewing',
currentStep: `검수 라운드 ${ev.round} (${ev.inspectorVerdict}/${ev.ceoVerdict})`,
recentLogs: this._pixelOfficeAppendLog(
`R${ev.round} insp:${ev.inspectorVerdict} ceo:${ev.ceoVerdict}`,
),
}, {
bubbleAgentId: ev.inspectorAgentId,
bubbleEvent: ev.inspectorVerdict === 'pass' && ev.ceoVerdict === 'pass'
? 'review_passed'
: (ev.inspectorVerdict === 'revise' ? 'review_failed' : undefined),
});
return;
case 'review-end':
this._pixelOfficeBroadcast({
status: ev.final === 'aborted' ? 'error' : 'executing',
currentStep: ev.final === 'pass'
? `검수 통과 (${ev.rounds}라운드)`
: ev.final === 'maxed-out'
? `검수 한도 도달 — 진행 (${ev.rounds}라운드)`
: '검수 중단',
recentLogs: this._pixelOfficeAppendLog(`🔍 검수 종료: ${ev.final}`),
}, {
bubbleEvent: ev.final === 'pass' ? 'review_passed'
: ev.final === 'aborted' ? 'risky_change_detected'
: undefined,
});
return;
case 'awaiting-approval':
this._pixelOfficeBroadcast({
status: 'waiting_approval',
currentStep: `승인 대기 — ${ev.stageLabel}`,
awaitingApproval: `${ev.stageLabel} 단계 완료 검토`,
recentLogs: this._pixelOfficeAppendLog(`✋ 승인 대기: ${ev.stageLabel}`),
}, {
bubbleStatus: 'waiting_approval',
bubbleEvent: 'approval_required',
});
return;
case 'approval-resolved':
this._pixelOfficeBroadcast({
status: 'executing',
awaitingApproval: undefined,
currentStep: `승인 결과: ${ev.decision}`,
recentLogs: this._pixelOfficeAppendLog(`✋→ ${ev.decision}`),
});
return;
case 'report-start':
this._pixelOfficeBroadcast({
status: 'reviewing',
currentStep: 'CEO 종합 보고서 작성 중',
recentLogs: this._pixelOfficeAppendLog('🧭 보고서 작성'),
});
return;
case 'report-done':
this._pixelOfficeBroadcast({
status: 'done',
currentStep: ev.ok ? '보고서 완료' : '보고서 (fallback) 완료',
progress: 1,
recentLogs: this._pixelOfficeAppendLog(ev.ok ? '✅ 보고서 OK' : '⚠ fallback 보고서'),
}, {
bubbleStatus: 'done',
bubbleEvent: 'task_completed',
});
return;
case 'session-saved':
this._pixelOfficeBroadcast({
status: 'done',
message: `세션 저장됨`,
recentLogs: this._pixelOfficeAppendLog('💾 세션 저장'),
});
return;
case 'aborted':
this._pixelOfficeBroadcast({
status: 'error',
currentStep: `중단: ${ev.reason}`,
recentLogs: this._pixelOfficeAppendLog(`🛑 abort: ${ev.reason}`),
}, {
bubbleStatus: 'error',
bubbleEvent: 'error_occurred',
});
return;
case 'telegram-mirror':
default:
return; // 시각화에 의미 약함 — log skip
}
}
/** webview가 처음 로드되거나 사용자가 토글을 다시 켰을 때 캐시된 상태 재전송. */
pixelOfficeResend(): void {
const cfg = getConfig();
const payload = (() => {
if (!cfg.companyPixelOfficeEnabled) {
return {
type: 'pixelOfficeUpdate' as const,
value: { state: null, bubbles: [], config: { enabled: false, bubblesEnabled: false, maxVisibleBubbles: 0, bubbleDurationMs: 0 } },
};
}
const state = this._pixelOfficeState ?? {
agentId: 'main', agentName: 'Agent',
status: 'idle' as import('./features/company').AgentStatus,
recentLogs: [],
updatedAt: Date.now(),
};
return {
type: 'pixelOfficeUpdate' as const,
value: {
state, bubbles: [],
config: {
enabled: cfg.companyPixelOfficeEnabled,
bubblesEnabled: cfg.companyPixelOfficeBubbles,
maxVisibleBubbles: 3,
bubbleDurationMs: 4500,
},
},
};
})();
this._view?.webview.postMessage(payload);
this._pixelOfficePanel?.webview.postMessage(payload);
}
/**
* editor area에 별도 Pixel Office 전체보기 panel을 띄움. 이미 열려 있으면
* 그 panel을 reveal. 사이드바 mini 패널과 동일한 데이터 스트림을 받지만
* 별도 HTML로 *사무실 그리드 + 직군별 캐릭터* 레이아웃을 보여준다.
*/
public openPixelOfficePanel(column: vscode.ViewColumn = vscode.ViewColumn.Beside): vscode.WebviewPanel {
if (this._pixelOfficePanel) {
this._pixelOfficePanel.reveal(column);
return this._pixelOfficePanel;
}
const panel = vscode.window.createWebviewPanel(
'astra.pixelOffice',
'Pixel Office',
column,
{ enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true },
);
this._pixelOfficePanel = panel;
panel.webview.html = this._buildPixelOfficeHtml(panel.webview);
// panel과 백엔드 사이의 메시지 채널 — 닫기/리프레시 + 레이아웃 편집.
panel.webview.onDidReceiveMessage((msg: any) => {
if (!msg || typeof msg !== 'object') return;
if (msg.type === 'getPixelOfficeState') this.pixelOfficeResend();
if (msg.type === 'closePixelOfficePanel') panel.dispose();
if (msg.type === 'getPixelOfficeLayout') {
// webview가 layout 요청 — 저장된 게 있으면 그것, 없으면 null.
panel.webview.postMessage({
type: 'pixelOfficeLayoutLoaded',
value: this._readPixelOfficeLayout() ?? null,
});
}
if (msg.type === 'savePixelOfficeLayout') {
// webview의 편집 모드에서 사용자가 저장 버튼 누름.
void this._writePixelOfficeLayout(msg.value).then(() => {
panel.webview.postMessage({
type: 'pixelOfficeLayoutSaved', value: { ok: true },
});
});
}
if (msg.type === 'resetPixelOfficeLayout') {
// 디폴트 LAYOUT으로 복귀.
void this._clearPixelOfficeLayout().then(() => {
panel.webview.postMessage({
type: 'pixelOfficeLayoutSaved', value: { ok: true, reset: true },
});
});
}
if (msg.type === 'pixelOfficeCommand') {
// D. 캐릭터 컨텍스트 메뉴 액션. 현재 abort만 처리 — Stop 버튼과
// 동일 경로(abortCompanyTurn + alignment 취소 + agent.stop)로
// 작업 즉시 중단. skip/pause 등은 dispatcher에 별도 API가 없어 미지원.
if (msg.cmd === 'abort') {
this.abortCompanyTurn();
this.cancelPendingAlignment();
this._agent.stop();
}
}
});
panel.onDidDispose(() => {
if (this._pixelOfficePanel === panel) this._pixelOfficePanel = undefined;
});
// 열자마자 현재 상태 한 번 push.
this.pixelOfficeResend();
return panel;
}
/**
* Pixel Office panel용 HTML 본문. 사이드바 mini와 같은 메시지 스키마를
* 받지만 그리는 방식이 전혀 달라(사무실 그리드 + 직군별 캐릭터) 사이드바와
* 분리된 별도 HTML을 둔다. 외부 CSS/JS 파일을 안 쓰고 한 파일에 묶어
* VS Code의 localResourceRoots 제약을 신경 안 쓰도록 설계.
*/
private _buildPixelOfficeHtml(webview: vscode.Webview): string {
const cspSource = webview.cspSource;
const derivedBase = webview.asWebviewUri(
vscode.Uri.joinPath(this._extensionUri, 'assets', 'pixelOffice', 'derived')
).toString();
return _pixelOfficePanelHtml(cspSource, { derivedBase });
}
/** Alignment 슬롯 비우기 — 사용자가 "취소"를 눌렀거나 turn 시작/종료 시점 호출. */
clearPendingAlignment(): void {
this._pendingAlignment = undefined;
}
/** Active file-system watcher for the project-architecture auto-update. Disposed on switch/detach. */
private _archWatcher?: vscode.FileSystemWatcher;
/** Debounce timer for the architecture watcher. */
private _archWatchDebounce?: NodeJS.Timeout;
/** Project ID the current watcher is watching — kept so we don't double-register. */
private _archWatchedProjectId?: string;
constructor(
readonly _extensionUri: vscode.Uri,
readonly _context: vscode.ExtensionContext,
readonly _agent: AgentExecutor,
readonly _lmStudio?: SidebarLmStudioDeps
) {
this._agent.setHistoryChangeListener((history) => {
void this._persistLastVisibleChat(history);
});
}
/** Surface LM Studio lifecycle errors (load/unload failures) to the chat UI as a non-fatal toast. */
public postLmStudioError(message: string): void {
this._view?.webview.postMessage({ type: 'lmStudioError', value: message });
}
public resolveWebviewView(
webviewView: vscode.WebviewView,
context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken,
) {
this._initView(webviewView);
}
/**
* Open the chat as a standalone editor panel (Column 3 by default).
* Reuses the same view-init logic via a WebviewPanel→WebviewView adapter
* so the rest of the provider keeps using `this._view` unchanged.
*/
public openAsPanel(column: vscode.ViewColumn = vscode.ViewColumn.Three): vscode.WebviewPanel {
if (this._panel) {
this._panel.reveal(column);
return this._panel;
}
const panel = vscode.window.createWebviewPanel(
SidebarChatProvider.viewType,
'Astra Chat',
column,
{ enableScripts: true, localResourceRoots: [this._extensionUri], retainContextWhenHidden: true }
);
this._panel = panel;
const adapter = wrapPanelAsView(panel);
panel.onDidDispose(() => {
if (this._panel === panel) this._panel = undefined;
if (this._view === adapter) this._view = undefined;
});
this._initView(adapter);
return panel;
}
private _initView(webviewView: vscode.WebviewView) {
this._view = webviewView;
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [this._extensionUri]
};
// [State Persistence Fix] 사이드바가 다시 보여질 때 세팅값 자동 복원
let _lastVisibilityRefresh = 0;
webviewView.onDidChangeVisibility(() => {
if (!webviewView.visible) return;
const now = Date.now();
// 5초 이내에 이미 갱신했으면 건너뜀
if (now - _lastVisibilityRefresh < 5000) return;
_lastVisibilityRefresh = now;
logInfo('Astra view became visible, restoring state...');
void this._sendModels();
void this._sendBrainProfiles();
void this._sendAgentsList();
void this._sendReadyStatus();
});
webviewView.webview.html = this._getHtml(webviewView.webview);
this._agent.setWebview(webviewView.webview);
void this._restoreActiveSessionIntoView();
void this._sendReadyStatus();
webviewView.webview.onDidReceiveMessage(async (data) => {
if (await handleChatMessage(this, data)) return;
if (await handleBrainMessage(this, data)) return;
if (await handleChronicleMessage(this, data)) return;
if (await handleAgentMessage(this, data)) return;
logInfo(`Unhandled sidebar message: ${data?.type}`);
});
}
_currentSessionId: string | null = null;
async _restoreActiveSessionIntoView() {
if (!this._view) return;
const blankChatActive = this._context.globalState.get<boolean>(SidebarChatProvider.blankChatStateKey, false);
const currentHistory = this._agent.getHistory();
if (blankChatActive && currentHistory.length === 0) {
return;
}
const activeSessionId = this._currentSessionId || this._context.globalState.get<string | null>(SidebarChatProvider.activeSessionStateKey, null);
if (activeSessionId) {
const loaded = await this._loadSession(activeSessionId, true);
if (loaded) return;
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
}
if (currentHistory.length > 0) {
this._view.webview.postMessage({ type: 'restoreHistory', value: currentHistory });
await this._persistLastVisibleChat(currentHistory);
return;
}
const snapshot = this._context.globalState.get<LastVisibleChatSnapshot | null>(SidebarChatProvider.lastVisibleChatStateKey, null);
if (snapshot?.history?.length) {
this._currentSessionId = snapshot.sessionId || null;
this._currentSessionBrainId = snapshot.brainProfileId || getActiveBrainProfile().id;
this._currentNegativePrompt = snapshot.negativePrompt || '';
await this._setActiveBrainProfile(this._currentSessionBrainId, true);
this._agent.setHistory(snapshot.history);
this._view.webview.postMessage({
type: 'restoreHistory',
value: snapshot.history,
negativePrompt: this._currentNegativePrompt
});
}
}
async _persistLastVisibleChat(history: ChatMessage[] = this._agent.getHistory()) {
if (history.length === 0) {
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
return;
}
const snapshot: LastVisibleChatSnapshot = {
history,
brainProfileId: this._currentSessionBrainId || getActiveBrainProfile().id,
sessionId: this._currentSessionId,
timestamp: Date.now(),
negativePrompt: this._currentNegativePrompt
};
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, snapshot);
}
async _saveCurrentSession() {
const history = this._agent.getHistory();
if (history.length === 0) return;
let sessions = this._getSessions();
const firstMsg = history.find(m => m.role === 'user')?.content;
const title = typeof firstMsg === 'string' ? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '') : 'New Chat';
const brainProfileId = this._currentSessionBrainId || getActiveBrainProfile().id;
if (!this._currentSessionId) {
this._currentSessionId = Date.now().toString();
sessions.unshift({
id: this._currentSessionId,
title,
timestamp: Date.now(),
history,
brainProfileId,
negativePrompt: this._currentNegativePrompt
});
} else {
const idx = sessions.findIndex(s => s.id === this._currentSessionId);
if (idx >= 0) {
sessions[idx].history = history;
sessions[idx].timestamp = Date.now();
sessions[idx].brainProfileId = brainProfileId;
sessions[idx].negativePrompt = this._currentNegativePrompt;
if (!sessions[idx].title || sessions[idx].title === 'New Chat') {
sessions[idx].title = title;
}
} else {
sessions.unshift({
id: this._currentSessionId,
title,
timestamp: Date.now(),
history,
brainProfileId,
negativePrompt: this._currentNegativePrompt
});
}
}
// Keep only last 50 sessions
if (sessions.length > 50) sessions = sessions.slice(0, 50);
await this._putSessions(sessions);
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, this._currentSessionId);
await this._persistLastVisibleChat(history);
await this._sendSessionList();
}
async _sendSessionList() {
if (!this._view) return;
const sessions = this._getSessions();
const list = sessions.map(s => ({
id: s.id,
title: s.title,
timestamp: s.timestamp,
brainProfileId: s.brainProfileId || '',
messageCount: s.history.length,
history: s.history
}));
this._view.webview.postMessage({ type: 'sessionList', value: list });
}
async _loadSession(id: string, skipSessionListRefresh: boolean = false): Promise<boolean> {
if (!id) {
logError('Session load requested without an id.');
this._view?.webview.postMessage({ type: 'error', value: 'Chat session id is missing.' });
return false;
}
const sessions = this._getSessions();
const session = sessions.find(s => s.id === id) || this._getSessionById(id);
if (session) {
const history = Array.isArray(session.history) ? session.history : [];
if (history.length === 0) {
logError('Session load failed because history is empty or invalid.', { id });
this._view?.webview.postMessage({ type: 'error', value: 'This chat session has no saved messages.' });
return false;
}
this._agent.stop();
this._currentSessionId = id;
this._currentNegativePrompt = session.negativePrompt || '';
const sessionBrainId = session.brainProfileId || getActiveBrainProfile().id;
await this._setActiveBrainProfile(sessionBrainId, true);
this._agent.setHistory(history);
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, id);
await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, false);
await this._persistLastVisibleChat(history);
this._view?.webview.postMessage({
type: 'sessionLoaded',
value: {
id,
title: session.title || 'Chat Session',
history,
negativePrompt: this._currentNegativePrompt
}
});
if (!skipSessionListRefresh) {
await this._sendSessionList();
}
logInfo('Chat session loaded.', { id, messages: history.length });
return true;
}
logError('Session load failed because id was not found.', { id, sessionCount: sessions.length });
this._view?.webview.postMessage({ type: 'error', value: 'Chat session was not found.' });
return false;
}
async _deleteSession(id: string) {
let sessions = this._getSessions();
sessions = sessions.filter(s => s.id !== id);
await this._putSessions(sessions);
if (this._currentSessionId === id) {
this._currentSessionId = null;
this._agent.resetConversation();
await this._context.globalState.update(SidebarChatProvider.activeSessionStateKey, null);
await this._context.globalState.update(SidebarChatProvider.lastVisibleChatStateKey, null);
await this._context.globalState.update(SidebarChatProvider.blankChatStateKey, true);
this.clearChat();
}
await this._sendSessionList();
}
_getSessions(): ChatSession[] {
const rawSessions = this._context.globalState.get<any[]>('chat_sessions', []) || [];
return rawSessions
.map((session, index): ChatSession | null => {
const history = Array.isArray(session?.history)
? session.history.filter((message: any) =>
message
&& (message.role === 'user' || message.role === 'assistant' || message.role === 'system')
&& message.content !== undefined
)
: [];
if (!session?.id || history.length === 0) return null;
const firstMsg = history.find((message: ChatMessage) => message.role === 'user')?.content;
const fallbackTitle = typeof firstMsg === 'string'
? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '')
: `Chat ${index + 1}`;
return {
id: String(session.id),
title: String(session.title || fallbackTitle),
timestamp: typeof session.timestamp === 'number' ? session.timestamp : Date.now(),
history,
brainProfileId: String(session.brainProfileId || getActiveBrainProfile().id),
negativePrompt: String(session.negativePrompt || '')
};
})
.filter((session): session is ChatSession => !!session)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 50);
}
_getSessionById(id: string): ChatSession | null {
const rawSessions = this._context.globalState.get<any[]>('chat_sessions', []) || [];
const raw = rawSessions.find((session: any) => String(session?.id) === String(id));
if (!raw) return null;
const history = Array.isArray(raw.history)
? raw.history.filter((message: any) =>
message
&& (message.role === 'user' || message.role === 'assistant' || message.role === 'system')
&& message.content !== undefined
)
: [];
if (history.length === 0) return null;
const firstMsg = history.find((message: ChatMessage) => message.role === 'user')?.content;
const fallbackTitle = typeof firstMsg === 'string'
? firstMsg.substring(0, 50).replace(/\n/g, ' ') + (firstMsg.length > 50 ? '...' : '')
: 'Chat Session';
return {
id: String(raw.id),
title: String(raw.title || fallbackTitle),
timestamp: typeof raw.timestamp === 'number' ? raw.timestamp : Date.now(),
history,
brainProfileId: String(raw.brainProfileId || getActiveBrainProfile().id),
negativePrompt: String(raw.negativePrompt || '')
};
}
async _putSessions(sessions: ChatSession[]) {
await this._context.globalState.update('chat_sessions', sessions.slice(0, 50));
}
async _sendBrainStatus() {
if (!this._view) return;
const activeBrain = getActiveBrainProfile();
const brainDir = activeBrain.localBrainPath;
const files = findBrainFiles(brainDir);
this._view.webview.postMessage({
type: 'brainStatus',
value: {
count: files.length,
path: brainDir,
name: activeBrain.name,
description: activeBrain.description || '',
repo: activeBrain.secondBrainRepo || ''
}
});
}
/**
* One-line "current readiness" snapshot for the sidebar's status bar:
* engine online?, model loaded?, Brain file count, active Agent + mapped knowledge
* folder count, memory on/off, context window. Cheap — no network calls except the
* already-cached LM Studio loaded-models list and online flag.
*/
async _sendReadyStatus() {
if (!this._view) return;
let payload: any;
try {
const config = getConfig();
const engineKind = resolveEngine(config.ollamaUrl);
const activeBrain = getActiveBrainProfile();
let brainFiles = 0;
try { brainFiles = findBrainFiles(activeBrain.localBrainPath).length; } catch { /* ignore */ }
const agentPath = this._context.globalState.get<string>(SidebarChatProvider.lastAgentStateKey, 'none');
let agentName: string | null = null;
let scopeFolders = 0;
let mapped = false;
if (agentPath && agentPath !== 'none') {
agentName = path.basename(agentPath).replace(/\.md$/i, '');
try {
const scope = resolveScopeForAgent(agentPath, activeBrain.localBrainPath || '');
scopeFolders = scope.folders.length;
if (scope.agent?.name) agentName = scope.agent.name;
mapped = scope.source !== 'none';
} catch { /* ignore */ }
}
let modelLoaded: boolean | null = null;
if (engineKind === 'lmstudio') {
try {
const loaded = (await this._lmStudio?.loadedModels()) || [];
modelLoaded = loaded.includes(config.defaultModel);
} catch { modelLoaded = null; }
}
const paramB = estimateModelParamsB(config.defaultModel);
const cappedForSmallModel = config.smallModelContextCap > 0
&& paramB !== null && paramB <= 4
&& config.contextLength > config.smallModelContextCap;
const effectiveContextLength = cappedForSmallModel ? config.smallModelContextCap : config.contextLength;
payload = {
engine: {
kind: engineKind,
label: engineKind === 'lmstudio' ? 'LM Studio' : 'Ollama',
online: this._modelsCache?.online ?? null,
},
model: { name: config.defaultModel, loaded: modelLoaded, paramB },
brain: { name: activeBrain.name, files: brainFiles },
agent: { name: agentName, scopeFolders, mapped },
memory: config.memoryEnabled,
multiAgent: config.multiAgentEnabled,
contextLength: effectiveContextLength,
nominalContextLength: config.contextLength,
cappedForSmallModel,
};
} catch (err: any) {
logError('Failed to build ready status.', { error: err?.message || String(err) });
return;
}
this._view.webview.postMessage({ type: 'readyStatus', value: payload });
}
async _sendBrainProfiles() {
if (!this._view) return;
const activeBrain = getActiveBrainProfile();
this._currentSessionBrainId = this._currentSessionBrainId || activeBrain.id;
const profiles = getBrainProfiles().map((profile) => ({
id: profile.id,
name: profile.name,
path: profile.localBrainPath,
description: profile.description || '',
repo: profile.secondBrainRepo || ''
}));
this._view.webview.postMessage({
type: 'brainProfiles',
value: {
activeBrainId: activeBrain.id,
profiles
}
});
void this._sendReadyStatus();
}
_postBrainProfiles(profiles: any[], activeBrainId: string) {
if (!this._view) return;
this._view.webview.postMessage({
type: 'brainProfiles',
value: {
activeBrainId,
profiles: profiles.map((p: any) => ({
id: p.id || '',
name: p.name || '',
path: p.localBrainPath || '',
description: p.description || '',
repo: p.secondBrainRepo || ''
}))
}
});
}
async _setActiveBrainProfile(profileId: string, silent: boolean = false) {
const profiles = getBrainProfiles();
const nextProfile = profiles.find((profile) => profile.id === profileId) || profiles[0];
if (!nextProfile) return;
await vscode.workspace.getConfiguration('g1nation').update('activeBrainId', nextProfile.id, vscode.ConfigurationTarget.Global);
this._currentSessionBrainId = nextProfile.id;
await this._sendBrainProfiles();
await this._sendBrainStatus();
if (!silent) {
this.injectSystemMessage(`**[Brain Switched]** ${nextProfile.name}\n\`${nextProfile.localBrainPath}\``);
}
}
async _manageBrains() {
const activeBrain = getActiveBrainProfile();
const choice = await vscode.window.showQuickPick([
{
label: 'Add Brain Folder',
description: 'Create a new brain profile from a local folder'
},
{
label: 'Open Active Brain Folder',
description: activeBrain.localBrainPath
},
{
label: 'Open Brain Settings',
description: 'Edit names, paths, repos, and descriptions'
}
], {
placeHolder: `Active Brain: ${activeBrain.name} (${activeBrain.localBrainPath})`
});
if (!choice) return;
if (choice.label === 'Add Brain Folder') {
await this._addBrainProfile();
return;
}
if (choice.label === 'Open Active Brain Folder') {
if (!fs.existsSync(activeBrain.localBrainPath)) {
fs.mkdirSync(activeBrain.localBrainPath, { recursive: true });
}
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(activeBrain.localBrainPath));
return;
}
vscode.commands.executeCommand('workbench.action.openSettings', 'g1nation.brainProfiles');
}
async _addBrainProfile() {
const selected = await vscode.window.showOpenDialog({
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: 'Use as Brain'
});
const folder = selected?.[0]?.fsPath;
if (!folder) return;
const defaultName = path.basename(folder) || 'New Brain';
const name = await vscode.window.showInputBox({
prompt: 'Name this brain profile',
value: defaultName,
validateInput: (value) => value.trim() ? null : 'Brain name is required.'
});
if (!name) return;
const description = await vscode.window.showInputBox({
prompt: 'Optional description shown in the Astra sidebar',
value: ''
});
const repo = await vscode.window.showInputBox({
prompt: 'Optional Second Brain Git repository URL',
value: ''
});
// Read raw settings directly to avoid virtual default-brain (injected in memory by getConfig())
// being saved into the settings file and corrupting the profile list on next load.
const cfg = vscode.workspace.getConfiguration('g1nation');
const existingRaw: any[] = cfg.get<any[]>('brainProfiles', []) || [];
const idBase = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'brain';
let id = idBase;
let suffix = 2;
while (existingRaw.some((p: any) => p.id === id)) {
id = `${idBase}-${suffix++}`;
}
const newProfile = {
id,
name: name.trim(),
localBrainPath: folder,
secondBrainRepo: (repo || '').trim(),
description: (description || '').trim()
};
const nextProfiles = [...existingRaw, newProfile];
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
await cfg.update('activeBrainId', id, vscode.ConfigurationTarget.Global);
this._currentSessionBrainId = id;
// cfg.update() is async and VSCode's config cache may not reflect the new value
// immediately, so we post the freshly-built profile list directly.
this._postBrainProfiles(nextProfiles, id);
await this._sendBrainStatus();
this.injectSystemMessage(`**[Brain Added]** ${name.trim()}\n\`${folder}\``);
}
async _editBrainProfile(profileId?: string) {
const currentProfiles = getBrainProfiles();
const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile();
if (!target) return;
const name = await vscode.window.showInputBox({
prompt: 'Edit brain profile name',
value: target.name,
validateInput: (value) => value.trim() ? null : 'Brain name is required.'
});
if (!name) return;
const folder = await vscode.window.showInputBox({
prompt: 'Edit local brain folder path',
value: target.localBrainPath,
validateInput: (value) => value.trim() ? null : 'Brain folder path is required.'
});
if (!folder) return;
const description = await vscode.window.showInputBox({
prompt: 'Edit optional description shown in the Astra sidebar',
value: target.description || ''
});
const repo = await vscode.window.showInputBox({
prompt: 'Edit optional Second Brain Git repository URL',
value: target.secondBrainRepo || ''
});
const nextProfiles = currentProfiles.map((profile) => profile.id === target.id
? {
...profile,
name: name.trim(),
localBrainPath: folder.trim(),
secondBrainRepo: (repo || '').trim(),
description: (description || '').trim()
}
: profile
);
const cfg = vscode.workspace.getConfiguration('g1nation');
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
await cfg.update('activeBrainId', target.id, vscode.ConfigurationTarget.Global);
this._currentSessionBrainId = target.id;
this._postBrainProfiles(nextProfiles, target.id);
await this._sendBrainStatus();
this.injectSystemMessage(`**[Brain Updated]** ${name.trim()}\n\`${folder.trim()}\``);
}
async _deleteBrainProfile(profileId?: string) {
const currentProfiles = getBrainProfiles();
const target = currentProfiles.find((profile) => profile.id === profileId) || getActiveBrainProfile();
if (!target) return;
if (currentProfiles.length <= 1) {
vscode.window.showWarningMessage('At least one brain profile is required.');
return;
}
const confirm = await vscode.window.showWarningMessage(
`Delete brain profile "${target.name}"? The folder itself will not be deleted.`,
{ modal: true },
'Delete Profile'
);
if (confirm !== 'Delete Profile') return;
const nextProfiles = currentProfiles.filter((profile) => profile.id !== target.id);
const nextActive = nextProfiles[0];
const cfg = vscode.workspace.getConfiguration('g1nation');
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
await cfg.update('activeBrainId', nextActive.id, vscode.ConfigurationTarget.Global);
this._currentSessionBrainId = nextActive.id;
this._postBrainProfiles(nextProfiles, nextActive.id);
await this._sendBrainStatus();
this.injectSystemMessage(`**[Brain Deleted]** ${target.name}`);
}
async _saveWikiRaw() {
const history = this._agent.getHistory();
if (history.length === 0) {
vscode.window.showWarningMessage('There is no conversation to save as wiki raw data.');
return;
}
const activeBrain = getActiveBrainProfile();
const rawDir = path.join(activeBrain.localBrainPath, 'raw-data');
if (!fs.existsSync(rawDir)) {
fs.mkdirSync(rawDir, { recursive: true });
}
const category = await vscode.window.showInputBox({
prompt: 'Wiki raw data category',
value: 'Project Notes'
});
if (category === undefined) return;
const expectedValue = await vscode.window.showInputBox({
prompt: 'Expected value or future use',
value: 'Reusable as source material for a future wiki document.'
});
if (expectedValue === undefined) return;
const timestamp = new Date();
const slug = this._slugify(history.find((message) => message.role === 'user')?.content || 'conversation');
const fileName = `${this._formatTimestampForFile(timestamp)}-${slug}.md`;
const filePath = path.join(rawDir, fileName);
const markdown = this._buildWikiRawMarkdown(history, {
category: category.trim() || 'Project Notes',
expectedValue: expectedValue.trim() || 'Reusable as source material for a future wiki document.',
activeBrainName: activeBrain.name,
activeBrainPath: activeBrain.localBrainPath,
createdAt: timestamp.toISOString()
});
await vscode.workspace.fs.writeFile(vscode.Uri.file(filePath), Buffer.from(markdown, 'utf8'));
vscode.window.showInformationMessage(`Wiki raw data saved: ${path.basename(filePath)}`);
this.injectSystemMessage(`**[Wiki Raw Saved]** \`${filePath}\``);
}
_buildWikiRawMarkdown(history: ChatMessage[], meta: {
category: string;
expectedValue: string;
activeBrainName: string;
activeBrainPath: string;
createdAt: string;
}): string {
const firstUserMessage = history.find((message) => message.role === 'user')?.content || '';
const latestUserMessage = [...history].reverse().find((message) => message.role === 'user')?.content || '';
const latestAssistantMessage = [...history].reverse().find((message) => message.role === 'assistant')?.content || '';
const rationaleNotes = history
.filter((message) => message.role === 'assistant' && message.rationale)
.map((message, index) => [
`### Process Note ${index + 1}`,
message.rationale?.problem ? `- Problem: ${message.rationale.problem}` : '',
message.rationale?.goal ? `- Goal: ${message.rationale.goal}` : '',
message.rationale?.reasoning ? `- Reasoning: ${message.rationale.reasoning}` : ''
].filter(Boolean).join('\n'))
.join('\n\n');
const transcript = history
.map((message, index) => [
`### ${index + 1}. ${message.role.toUpperCase()}`,
'',
message.content.trim() || '(empty)'
].join('\n'))
.join('\n\n');
return [
'---',
`title: "${this._escapeYamlString(this._summarizeForTitle(firstUserMessage))}"`,
`category: "${this._escapeYamlString(meta.category)}"`,
`created_at: "${meta.createdAt}"`,
`source: "Astra conversation"`,
`brain: "${this._escapeYamlString(meta.activeBrainName)}"`,
'status: raw',
'---',
'',
`# ${this._summarizeForTitle(firstUserMessage)}`,
'',
'## Category',
meta.category,
'',
'## What This Contains',
this._summarizeTextForWiki(latestAssistantMessage || firstUserMessage),
'',
'## Expected Value',
meta.expectedValue,
'',
'## Discovery Process',
rationaleNotes || 'No explicit rationale metadata was captured. Use the transcript below to reconstruct the reasoning path.',
'',
'## Conclusion',
[
'This conclusion was derived from the latest user request and assistant response.',
'',
`- Latest user request: ${this._summarizeTextForWiki(latestUserMessage)}`,
`- Latest assistant conclusion: ${this._summarizeTextForWiki(latestAssistantMessage)}`
].join('\n'),
'',
'## Coding Implementation Notes',
'Use this section to turn the raw transcript into a wiki-ready implementation note. Capture which files changed, why they changed, and what verification was run.',
'',
'## Source Brain',
`- Name: ${meta.activeBrainName}`,
`- Path: ${meta.activeBrainPath}`,
'',
'## Raw Conversation Transcript',
transcript,
''
].join('\n');
}
_formatTimestampForFile(date: Date): string {
return date.toISOString().replace(/[:.]/g, '-').replace('T', '_').replace('Z', '');
}
_slugify(value: string): string {
const slug = value
.toLowerCase()
.replace(/[^a-z0-9가-힣]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 48);
return slug || 'conversation';
}
_summarizeForTitle(value: string): string {
const normalized = value.replace(/\s+/g, ' ').trim();
if (!normalized) return 'Astra Conversation Raw Data';
return normalized.length > 80 ? `${normalized.slice(0, 80)}...` : normalized;
}
_summarizeTextForWiki(value: string): string {
const normalized = value.replace(/\s+/g, ' ').trim();
if (!normalized) return 'Not captured.';
return normalized.length > 500 ? `${normalized.slice(0, 500)}...` : normalized;
}
_escapeYamlString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
// --- BridgeInterface Methods ---
public injectSystemMessage(msg: string): void {
this._view?.webview.postMessage({ type: 'streamStart' });
this._view?.webview.postMessage({ type: 'streamChunk', value: msg });
this._view?.webview.postMessage({ type: 'streamEnd' });
}
public getHistoryText(): string {
return "Conversation history placeholder for evaluation.";
}
public sendPromptFromExtension(prompt: string): void {
if (this._view) {
this._view.show?.(true);
this._view.webview.postMessage({ type: 'injectPrompt', value: prompt });
}
}
public findBrainFiles(dir: string): string[] {
return findBrainFiles(dir);
}
// --- End BridgeInterface ---
public focusInput() {
this._view?.webview.postMessage({ type: 'focusInput' });
}
public clearChat() {
this._view?.webview.postMessage({ type: 'clearChat' });
}
public async syncBrain() {
const activeBrain = getActiveBrainProfile();
const brainDir = activeBrain.localBrainPath;
if (!fs.existsSync(brainDir)) {
vscode.window.showErrorMessage("Second Brain directory not found.");
return;
}
vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: "Astra: Syncing Second Brain...",
cancellable: false
}, async () => {
try {
const { execSync } = require('child_process');
execSync(`git add .`, { cwd: brainDir });
execSync(`git commit -m "[G1-Sync] Manual knowledge update"`, { cwd: brainDir });
execSync(`git push`, { cwd: brainDir });
vscode.window.showInformationMessage("Second Brain synced successfully.");
} catch (err: any) {
vscode.window.showInformationMessage("Brain Synced: Local knowledge is up-to-date (no changes to push).");
}
});
}
_getChronicleProjects(): ProjectProfile[] {
const raw = this._context.globalState.get<ProjectProfile[]>(SidebarChatProvider.chronicleProjectsStateKey, []) || [];
const valid = raw.filter((profile: ProjectProfile) =>
profile
&& typeof profile.projectId === 'string'
&& typeof profile.projectName === 'string'
&& typeof profile.recordRoot === 'string'
);
if (valid.length > 0) return valid;
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) return [];
const now = new Date().toISOString();
const projectName = path.basename(workspaceRoot) || 'Current Project';
return [{
projectId: this._slugify(projectName),
projectName,
projectRoot: workspaceRoot,
recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName),
description: 'Auto-detected current workspace project.',
corePurpose: 'Capture project planning, decisions, development notes, bugs, and retrospectives as Markdown.',
targetUsers: ['Project developer'],
avoidDirections: ['Do not tightly couple records to chat execution internals.'],
detailLevel: 'standard',
createdAt: now,
updatedAt: now
}];
}
async _putChronicleProjects(projects: ProjectProfile[]) {
await this._context.globalState.update(SidebarChatProvider.chronicleProjectsStateKey, projects);
}
/**
* One-time repair for chronicle projects that were stored before the
* multi-subproject fix landed.
*
* Old activation code always treated `workspaceFolders[0]` (= the open
* parent folder, e.g. `/.../Antigravity`) as the project root, so every
* subproject the user activated (`ConnectAI`, `Datacollector_MAC`,
* `Skybound`, …) ended up with `projectRoot === <parent>` in globalState.
* That breaks reload: `_ensureActiveProjectForWorkspace` case 2 matches
* the corrupted entry on every boot and force-switches the active
* project to whichever bad row it found first.
*
* The repair: for each chronicle project whose `projectName` does NOT
* match the basename of its stored `projectRoot` (i.e. it can't actually
* be the project root), and where a same-name subfolder exists under
* the current workspace, retarget the entry to that subfolder. Idempotent
* — re-running it after the first pass changes nothing.
*/
async _repairCorruptedChronicleProjectRoots(): Promise<void> {
const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!wsRoot) return;
const projects = this._getChronicleProjects();
if (projects.length === 0) return;
const norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase();
const wsRootNorm = norm(wsRoot);
let changed = false;
const repaired = projects.map((p) => {
if (!p.projectName || !p.projectRoot) return p;
const rootBase = path.basename(p.projectRoot).toLowerCase();
const nameMatches = rootBase === p.projectName.toLowerCase();
if (nameMatches) return p; // root basename agrees with name → trust it
// Only repair entries that look like they were silently captured at
// the workspace parent. Anything else (different machine, custom
// path) we leave alone.
if (norm(p.projectRoot) !== wsRootNorm) return p;
const candidate = path.join(wsRoot, p.projectName);
try {
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isDirectory()) return p;
} catch {
return p;
}
const newDocPath = path.join(candidate, '.astra', 'project-context', 'architecture.md');
changed = true;
return {
...p,
projectRoot: candidate,
recordRoot: path.join(candidate, 'docs', 'records', p.projectName),
architectureDocPath: fs.existsSync(newDocPath) ? newDocPath : p.architectureDocPath,
};
});
if (!changed) return;
await this._putChronicleProjects(repaired);
const fixedCount = repaired.filter((p, i) => p !== projects[i]).length;
logInfo('architecture: repaired chronicle projects with wrong projectRoot.', {
wsRoot, count: fixedCount,
});
}
// ─── Project Architecture Context (Feature 2) ──────────────────────────────
//
// Activation flow:
// 1. Chat preprocessor (or an explicit "Activate" button) calls
// _tryActivateArchitectureFromText(latestUserMessage).
// 2. If the text yields a known/inferable project, we set it active,
// ensure the architecture doc exists, register the file watcher,
// and broadcast the state to the webview as a chip.
// 3. On every subsequent prompt, _handlePrompt reads
// _buildProjectArchitectureContext() and injects it into the model
// call. Detach → empty context + watcher disposed.
/** True if the active project has its architecture doc auto-attached. */
_isArchitectureAutoAttached(): boolean {
const p = this._getActiveChronicleProject();
return !!(p && p.architectureDocPath && p.architectureAutoAttach !== false);
}
/**
* Try to resolve a project handle from arbitrary user text. Combines:
* • Korean / English natural-language activation phrasing.
* • Absolute filesystem paths.
* • The existing Chronicle project list as ground truth for name matches.
*/
_detectProjectFromText(text: string): KnownProject | null {
const known = this._getChronicleProjects().map<KnownProject>((p) => ({
projectId: p.projectId,
projectName: p.projectName,
projectRoot: p.projectRoot,
}));
const hit = detectProjectIntent(text || '', known);
return hit?.project ?? null;
}
/**
* Activate (or refresh) architecture context for the project resolved from
* `text`. No-op when no project is detected. Returns the activated profile
* id, or `null` if nothing changed. Side-effects: writes the architecture
* doc, marks the project active, broadcasts the chip state.
*/
async _tryActivateArchitectureFromText(text: string): Promise<string | null> {
const detected = this._detectProjectFromText(text);
if (!detected) return null;
return this._activateArchitectureForProject(detected.projectId, {
fallbackName: detected.projectName,
fallbackRoot: detected.projectRoot,
});
}
/**
* Make `projectId` the active project, ensure its architecture doc exists,
* and register the file watcher. If the project isn't in the chronicle
* store yet (path-only match), materialise a minimal profile so subsequent
* turns can find it.
*/
async _activateArchitectureForProject(
projectId: string,
opts: { fallbackName?: string; fallbackRoot?: string } = {}
): Promise<string | null> {
const projects = this._getChronicleProjects();
let profile = projects.find((p) => p.projectId === projectId);
// Materialise a stub when the user references a project by path that
// isn't yet registered. We use the path's basename as the name and the
// standard records location as recordRoot so existing Chronicle code
// keeps working.
if (!profile) {
const root = opts.fallbackRoot || '';
if (!root) {
logError('architecture: cannot activate without project root.', { projectId });
return null;
}
const name = opts.fallbackName || path.basename(root) || projectId;
const now = new Date().toISOString();
profile = {
projectId,
projectName: name,
projectRoot: root,
recordRoot: path.join(root, 'docs', 'records', name),
description: 'Auto-created by Project Architecture activation.',
corePurpose: '',
detailLevel: 'standard',
createdAt: now,
updatedAt: now,
};
projects.push(profile);
await this._putChronicleProjects(projects);
}
if (!profile.projectRoot) {
logError('architecture: profile has no projectRoot; cannot scan.', { projectId });
return null;
}
// Generate or refresh the doc. Always idempotent — the generator
// preserves user-owned sections.
const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
const now = new Date().toISOString();
const updated: ProjectProfile = {
...profile,
architectureDocPath: result.docPath,
// [BUGFIX] Previously used `?? true`, but `??` only fallbacks on null/undefined.
// After a user Detach, `architectureAutoAttach === false`, so `false ?? true`
// stays `false` and the chip stays "detached — click Attach to re-enable"
// forever no matter how many times the user clicks Attach. The activation
// path is an explicit user intent to re-enable, so force `true` here.
architectureAutoAttach: true,
architectureAutoUpdate: profile.architectureAutoUpdate ?? true,
architectureLastUpdated: now,
architectureLastScanSignature: result.scan.signature,
updatedAt: now,
};
const next = projects.map((p) => (p.projectId === updated.projectId ? updated : p));
await this._putChronicleProjects(next);
await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, projectId);
// (Re)register the watcher for this project.
this._registerArchitectureWatcher(updated);
// Tell the webview to show / refresh the chip.
await this._sendArchitectureStatus();
logInfo('architecture: activated.', {
projectId, docPath: result.docPath, created: result.created,
});
return projectId;
}
/** Detach project mode: stop auto-attaching the doc and dispose the watcher. */
async _detachArchitecture(): Promise<void> {
const profile = this._getActiveChronicleProject();
if (!profile) {
this._disposeArchitectureWatcher();
await this._sendArchitectureStatus();
return;
}
const projects = this._getChronicleProjects();
const next = projects.map((p) => p.projectId === profile.projectId
? { ...p, architectureAutoAttach: false }
: p);
await this._putChronicleProjects(next);
this._disposeArchitectureWatcher();
await this._sendArchitectureStatus();
logInfo('architecture: detached.', { projectId: profile.projectId });
}
/**
* Force a refresh of the architecture doc for the active project.
*
* Always rewrites the auto-managed block (so the "Last Refresh" stamp +
* stats reflect the click). Emits an `architectureRefreshResult` event
* with the per-file work breakdown — that's what makes the operation
* visibly trustworthy in the UI (no more "0.1s, nothing visible").
*/
async _refreshArchitecture(): Promise<void> {
const startedAt = Date.now();
const profile = this._getActiveChronicleProject();
if (!profile || !profile.projectRoot) {
this._view?.webview.postMessage({
type: 'architectureRefreshFailed',
value: { reason: 'no-active-project' },
});
return;
}
const result = buildOrRefreshArchitectureDoc(profile.projectRoot, profile.projectName);
const now = new Date().toISOString();
const projects = this._getChronicleProjects();
const next = projects.map((p) => p.projectId === profile.projectId
? {
...p,
architectureDocPath: result.docPath,
architectureLastUpdated: now,
architectureLastScanSignature: result.scan.signature,
updatedAt: now,
}
: p);
await this._putChronicleProjects(next);
await this._sendArchitectureStatus();
// Tell the webview exactly what the scan did so the user can
// trust the "Refresh" button actually ran. The three numbers
// (newly / cached / deleted) together explain whether the doc
// changed or just had its timestamp bumped.
this._view?.webview.postMessage({
type: 'architectureRefreshResult',
value: {
projectName: profile.projectName,
docPath: result.docPath,
newlyAnalyzed: result.refreshStats.newlyAnalyzed,
cached: result.refreshStats.cached,
deleted: result.refreshStats.deleted.length,
durationMs: Date.now() - startedAt,
},
});
}
/**
* Re-attach the architecture context for the active project after a
* prior Detach. Rebuilds the doc (so the user gets a fresh scan),
* flips `architectureAutoAttach=true`, re-registers the watcher, and
* broadcasts the chip back to its active state. The complement of
* `_detachArchitecture`.
*/
async _attachArchitecture(): Promise<void> {
// `_ensureActiveProjectForWorkspace` guarantees the active project
// matches the current VS Code workspace — without that, hitting
// Attach right after opening a different folder would silently
// attach to whatever was last active in the *previous* workspace.
const profile = await this._ensureActiveProjectForWorkspace();
if (!profile || !profile.projectRoot) {
this._view?.webview.postMessage({
type: 'architectureRefreshFailed',
value: { reason: 'no-active-project' },
});
return;
}
await this._activateArchitectureForProject(profile.projectId, {
fallbackName: profile.projectName,
fallbackRoot: profile.projectRoot,
});
}
/**
* Make sure the active chronicle project actually corresponds to the
* folder the user has open in VS Code. Three cases:
*
* 1. Active project already matches workspace → return it as-is.
* 2. A *different* chronicle project matches the workspace → flip
* the active id to that one (the user switched folders since
* last session).
* 3. No chronicle project matches → synthesise a new one from the
* workspace folder name + register it.
*
* Returns the (possibly newly created) active project, or `null` when
* no workspace is open. Idempotent — calling repeatedly with no change
* is free.
*/
async _ensureActiveProjectForWorkspace(): Promise<ProjectProfile | null> {
const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!wsRoot) return null;
// When the parent folder contains several subprojects, use the active
// editor's location to pick the *effective* subproject root instead of
// always reporting the parent.
const hint = vscode.window.activeTextEditor?.document.uri.fsPath
?? vscode.window.visibleTextEditors[0]?.document.uri.fsPath;
const resolved = resolveActiveSubprojectRoot(wsRoot, hint);
const norm = (p: string | undefined) => (p || '').replace(/[\\/]+$/, '').toLowerCase();
const active = this._getActiveChronicleProject();
const projects = this._getChronicleProjects();
// Did the editor hint actually point at a nested subproject? If the
// resolver fell back to the workspace root (no nested marker, or the
// Astra sidebar itself was focused so no editor existed), we must NOT
// clobber the user's current active project — they may have set it by
// hand via the project picker, or by typing a project intent. Auto-
// switching is only safe when there is a clear editor-driven signal.
const isNestedHit = !!resolved && norm(resolved) !== norm(wsRoot);
if (!isNestedHit && active) {
return active;
}
const workspaceRoot = isNestedHit ? resolved : wsRoot;
if (active && active.projectRoot && norm(active.projectRoot) === norm(workspaceRoot)) {
return active;
}
// Case 2: another chronicle project matches → switch active to it
const matching = projects.find((p) => norm(p.projectRoot) === norm(workspaceRoot));
if (matching) {
await this._context.globalState.update(
SidebarChatProvider.activeChronicleProjectStateKey,
matching.projectId,
);
logInfo('architecture: switched active project to match workspace.', {
from: active?.projectId,
to: matching.projectId,
});
return matching;
}
// Case 3: synthesise a fresh entry for this workspace
const projectName = path.basename(workspaceRoot) || 'Current Project';
const projectId = this._slugify(projectName);
const now = new Date().toISOString();
const profile: ProjectProfile = {
projectId,
projectName,
projectRoot: workspaceRoot,
recordRoot: path.join(workspaceRoot, 'docs', 'records', projectName),
description: 'Auto-detected from workspace folder.',
corePurpose: '',
detailLevel: 'standard',
createdAt: now,
updatedAt: now,
};
const nextProjects = projects.filter((p) => p.projectId !== projectId).concat(profile);
await this._putChronicleProjects(nextProjects);
await this._context.globalState.update(
SidebarChatProvider.activeChronicleProjectStateKey,
projectId,
);
logInfo('architecture: registered new project from workspace.', {
projectId, projectRoot: workspaceRoot,
});
return profile;
}
/**
* Build the `projectArchitectureContext` string for the active prompt.
* Returns empty string when auto-attach is off or the doc is missing —
* agent.ts then treats it as "no block" and emits nothing extra.
*/
_buildProjectArchitectureContext(): string {
const p = this._getActiveChronicleProject();
if (!p || p.architectureAutoAttach === false || !p.architectureDocPath) return '';
if (!fs.existsSync(p.architectureDocPath)) return '';
return formatArchitectureContextForPrompt({
projectName: p.projectName,
docPath: p.architectureDocPath,
// Pass the project root so the `Source:` header in the prompt is
// workspace-relative — keeps the prompt portable across machines.
projectRoot: p.projectRoot,
lastUpdated: p.architectureLastUpdated,
});
}
/**
* Webview chip data. Three states:
*
* 1. **active** — project mode is on; doc is being auto-attached.
* 2. **inactive** — there's a project + workspace, but architecture
* is either never-activated or user-detached.
* The chip shows an `[Attach]` button instead of
* hiding entirely, so users always have a one-
* click path back into project mode.
* 3. **hidden** — no workspace open and no project at all.
*
* Also does an auto-activation pass for the *fresh-workspace* case:
* when the active project has no `architectureDocPath` yet AND the
* user hasn't explicitly detached, we generate the doc + flip
* `autoAttach=true` so the user opens a new folder and immediately
* sees the architecture context working. Existing detach choices are
* always respected.
*/
async _sendArchitectureStatus(): Promise<void> {
if (!this._view) return;
// Always sync the active project to the current VS Code workspace
// before reporting — otherwise switching workspaces leaves the
// chip pointing at the *previous* project's doc.
const p = await this._ensureActiveProjectForWorkspace();
if (!p) {
this._view.webview.postMessage({ type: 'architectureStatus', value: { active: false } });
return;
}
const wasDetached = p.architectureAutoAttach === false;
const hasDoc = !!(p.architectureDocPath && fs.existsSync(p.architectureDocPath));
// Auto-activation for fresh workspaces: never been activated AND
// never been detached → kick off a build and re-broadcast. Single
// recursion is safe because the post-activate state will hit the
// `active` branch below.
if (!hasDoc && !wasDetached && p.projectRoot) {
try {
await this._activateArchitectureForProject(p.projectId, {
fallbackName: p.projectName,
fallbackRoot: p.projectRoot,
});
return; // _activateArchitectureForProject sends its own status
} catch (e: any) {
logError('architecture: auto-activate failed.', { error: e?.message ?? String(e) });
// Fall through to the inactive state so the user still sees an Attach button.
}
}
const fullyActive = hasDoc && !wasDetached;
if (fullyActive) {
this._view.webview.postMessage({
type: 'architectureStatus',
value: {
active: true,
projectId: p.projectId,
projectName: p.projectName,
docPath: p.architectureDocPath,
lastUpdated: p.architectureLastUpdated || '',
autoUpdate: p.architectureAutoUpdate !== false,
},
});
// Re-register the watcher in case it was disposed (e.g. workspace switch).
this._registerArchitectureWatcher(p);
} else {
// Inactive but attachable: surface the project name + an Attach hook.
this._view.webview.postMessage({
type: 'architectureStatus',
value: {
active: false,
canAttach: !!p.projectRoot,
projectId: p.projectId,
projectName: p.projectName,
// Distinguishes "never activated" from "detached" so the
// chip can choose the right label ("Activate" vs "Re-attach").
detached: wasDetached,
},
});
}
}
// ─── 1인 기업 (Company) Mode ────────────────────────────────────────────
//
// When `companyState.enabled` is true, prompts coming through the chat
// handler are routed to `_runCompanyTurn` instead of the normal
// AgentExecutor path. The dispatcher emits `companyTurnUpdate` events as
// each phase progresses; the webview shows a step-by-step header for
// CEO planning, each specialist's dispatch, and the final synthesis.
/** True iff company mode is active. Cheap — read from globalState. */
isCompanyModeEnabled(): boolean {
return readCompanyState(this._context).enabled;
}
/**
* Abort the currently-running 1인 기업 turn if any. Returns true when an
* abort was actually fired (so the chat handler can skip `agent.stop()`
* — the company path never touches AgentExecutor). The dispatcher will
* see `signal.aborted` at its next phase boundary and emit
* `phase: 'aborted'`; `_runCompanyTurn`'s finally clause then posts
* `streamEnd` so the UI unlocks.
*/
abortCompanyTurn(): boolean {
if (!this._companyAbort) return false;
this._companyAbort.abort();
// 승인 게이트 대기 중인 모든 stage를 'abort'로 해제. 안 하면 dispatcher가
// 영원히 await 상태로 남아 turn이 절대 종료 안 됨.
for (const resolve of this._pendingApprovals.values()) {
try { resolve({ kind: 'abort' }); } catch { /* noop */ }
}
this._pendingApprovals.clear();
return true;
}
/**
* Clear the cached "last completed company turn" — called when the user
* starts a new chat / loads a different session. Without this, an old
* report would bleed into the next session's intent classifications and
* make stale "follow-up" verdicts.
*/
clearLastCompanyTurnSummary(): void {
this._lastCompanyTurnSummary = undefined;
}
/** Read accessor for the intent classifier. May return undefined on cold start. */
getLastCompanyTurnSummary() {
return this._lastCompanyTurnSummary;
}
/**
* Intent Alignment 1라운드 실행. new_task 분류 직후 (또는 사용자 답변 후)
* 호출되며 LLM 분석기를 한 번 돌려 contract를 채운다. confidence가 'high'
* (또는 strict 모드 아니고 medium)이면 곧장 pipeline dispatch로 넘어가고,
* 그 외엔 webview에 카드를 띄워 사용자 응답을 기다린다(_pendingAlignment).
*
* mode:
* - 'smart': high → 자동 dispatch, medium → 사용자 확인 카드, low → 질문 카드
* - 'strict': confidence 무관 항상 사용자 확인 카드
*
* roundsLimit를 넘기면 더 묻지 않고 현재 contract로 카드(확인)만 띄움.
*/
async _runIntentAlignment(opts: {
userPrompt: string;
previousContract?: import('./features/company').RequirementContract;
previousAnswers?: Array<{ q: string; a: string }>;
pipelineIdOverride?: string;
mode: 'smart' | 'strict';
roundsLimit: number;
roundsAsked: number;
}): Promise<void> {
const { analyzeIntent, readCompanyState, resolveActivePipeline, listActiveAgentsByCategory } =
await import('./features/company');
const { AIService } = await import('./core/services');
const cfg = getConfig();
const state = readCompanyState(this._context);
const activePipeline = resolveActivePipeline(state);
// 활성 직군 — 분석기가 "이 회사가 어떤 일을 할 수 있나"를 알아야
// goal/format을 그 능력에 맞춰 추출할 수 있다.
const byCat = listActiveAgentsByCategory(state);
const availableRoleCategories = Object.entries(byCat)
.filter(([, list]) => list.length > 0)
.map(([cat]) => cat);
// Pixel Office: 분석 시작 표시 (LLM 콜 직전).
try { this.pixelOfficeOnAlignmentStart(opts.userPrompt); } catch { /* noop */ }
const analysis = await analyzeIntent(
new AIService(),
{
userOriginalPrompt: opts.userPrompt,
previousAnswers: opts.previousAnswers,
previousContract: opts.previousContract,
activePipelineName: activePipeline?.name,
availableRoleCategories,
},
// 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함.
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
);
const contract = analysis.contract;
const mode = opts.mode;
const reachedLimit = opts.roundsAsked >= opts.roundsLimit;
// 자동 진행 조건: smart 모드 + high confidence + open question 없음.
// strict 모드면 절대 자동 진행 안 함 — 항상 사용자 확인.
const canAutoProceed = mode === 'smart'
&& contract.confidence === 'high'
&& contract.openQuestions.length === 0;
if (canAutoProceed) {
// contract 한 줄 안내 후 곧장 pipeline. 사용자 friction 최소.
this._view?.webview.postMessage({
type: 'companyAlignmentCard',
value: {
kind: 'auto-proceed',
contract,
},
});
try { this.pixelOfficeOnAlignmentResult('auto-proceed', contract); } catch { /* noop */ }
this._pendingAlignment = undefined;
await this._runCompanyTurn(opts.userPrompt, undefined, opts.pipelineIdOverride, contract);
return;
}
// 그 외 — 카드 표시 + 사용자 응답 대기. 라운드 한도 도달했거나
// openQuestions가 비어 있으면 "확인" 카드(질문 없음, 진행/취소 버튼만).
const askMode = reachedLimit || contract.openQuestions.length === 0
? 'confirm' : 'questions';
this._view?.webview.postMessage({
type: 'companyAlignmentCard',
value: {
kind: askMode,
contract,
roundsAsked: opts.roundsAsked,
roundsLimit: opts.roundsLimit,
},
});
try { this.pixelOfficeOnAlignmentResult(askMode, contract); } catch { /* noop */ }
this._pendingAlignment = {
userOriginalPrompt: opts.userPrompt,
contract,
roundsAsked: opts.roundsAsked,
pipelineIdOverride: opts.pipelineIdOverride,
};
// streamEnd 보내야 채팅 input 잠금이 풀려 사용자가 답변을 칠 수 있음.
// 평소 1인 기업 turn은 _runCompanyTurn finally에서 보내지만, alignment는
// dispatcher를 안 거치고 사용자 입력으로 unlock해야 하므로 명시적으로 push.
this._view?.webview.postMessage({ type: 'streamEnd' });
void this._sendReadyStatus();
}
/**
* 사용자가 alignment 카드 상태에서 채팅 입력(답변)을 보낸 경우 호출.
* 답변을 contract에 합쳐 분석기 재호출, 라운드를 한 칸 늘림.
*/
async _handleAlignmentAnswer(userMessage: string): Promise<void> {
const pending = this._pendingAlignment;
if (!pending) return;
const cfg = getConfig();
const mode = (cfg.companyIntentAlignmentMode === 'strict') ? 'strict' : 'smart';
const roundsLimit = Math.max(1, Math.min(5, cfg.companyIntentAlignmentMaxRounds ?? 3));
// 사용자 답변을 미해결 질문들에 일괄 매핑 — 작은 모델이 자연어로 통째로
// 답하기 마련이라 질문별로 분리 안 함. 그냥 "이번 라운드 사용자 추가
// 답변" 한 덩어리로 넣어주면 분석기가 다음 라운드에 그걸 보고 알아서
// 갱신한다.
const compositeAnswer = userMessage.trim();
const updatedAnswers = [
...pending.contract.answeredQuestions,
{ q: pending.contract.openQuestions.join(' / ') || '(추가 정보 요청)', a: compositeAnswer },
];
// 슬롯 비워두고 alignment 다시 돌림 — 새 결과가 다시 _pendingAlignment를
// 채울 것이고, 자동 진행 조건 충족 시 pipeline까지 갈 수도 있다.
this._pendingAlignment = undefined;
await this._runIntentAlignment({
userPrompt: pending.userOriginalPrompt,
previousContract: pending.contract,
previousAnswers: updatedAnswers,
pipelineIdOverride: pending.pipelineIdOverride,
mode,
roundsLimit,
roundsAsked: pending.roundsAsked + 1,
});
}
/**
* 사용자가 카드의 "✅ 진행" 버튼을 눌러 현 contract 그대로 dispatch
* 시키고 싶을 때. 슬롯 비우고 pipeline.
*/
async _proceedWithCurrentAlignment(): Promise<void> {
const pending = this._pendingAlignment;
if (!pending) return;
this._pendingAlignment = undefined;
await this._runCompanyTurn(
pending.userOriginalPrompt,
undefined,
pending.pipelineIdOverride,
pending.contract,
);
}
/**
* 사용자가 카드의 "🛑 취소"를 누르거나 alignment를 그만두려 할 때.
* 슬롯 비우고 webview에도 streamEnd push해서 input 풀어줌.
*/
cancelPendingAlignment(): void {
if (!this._pendingAlignment) return;
this._pendingAlignment = undefined;
this._view?.webview.postMessage({ type: 'companyAlignmentCard', value: { kind: 'cancelled' } });
this._view?.webview.postMessage({ type: 'streamEnd' });
try { this.pixelOfficeOnAlignmentCancelled(); } catch { /* noop */ }
void this._sendReadyStatus();
}
/**
* Casual chat path inside 1인 기업 모드 — used when the intent classifier
* routes a message to `chat` or `followup` instead of `new_task`. We
* deliberately *don't* spin up the dispatcher here: that surface is for
* multi-step work. Instead we route through the normal chat path (same
* AgentExecutor.handlePrompt used outside company mode) so streaming UI,
* brain retrieval, and the action-tag executor all work as users expect.
*
* The `reason` from the classifier is surfaced as a small label so the
* user can tell *why* their message wasn't treated as a new task — if
* they meant it as one, they can rephrase or override with a keyword
* like "파이프라인 돌려" / "기획해줘".
*/
async _handleCompanyCasual(prompt: string, intent: 'chat' | 'followup', reason: string, originalData: any): Promise<void> {
// 사용자에게 "왜 이게 가벼운 응답으로 갔는지" 보여주는 한 줄 라벨.
// 잘못 분류된 거라면 사용자가 즉시 인지하고 다시 말할 수 있어야 한다.
const label = intent === 'followup' ? '💬 후속 대화' : '💬 대화';
this._view?.webview.postMessage({
type: 'companyIntentDecision',
value: { intent, reason, label },
});
await this._handlePrompt(originalData);
await this._autoWriteChronicleAfterPrompt();
await this._saveCurrentSession();
}
/**
* Called by chatHandlers when the user clicks an approval card button.
* Resolves the dispatcher's awaitApproval promise for `stageId`. Idempotent
* — extra clicks after the first one are silently dropped.
*/
resolveApprovalGate(stageId: string, decision: import('./features/company/dispatcher').ApprovalDecision): boolean {
const resolve = this._pendingApprovals.get(stageId);
if (!resolve) return false;
this._pendingApprovals.delete(stageId);
try { resolve(decision); } catch { /* noop */ }
return true;
}
/**
* Push the full pipeline catalogue + active id to the webview so the
* editor overlay can render the cards. Pipelines are user-defined
* (no built-ins) so an empty list is the default for new users.
*/
async _sendCompanyPipelines(): Promise<void> {
if (!this._view) return;
const state = readCompanyState(this._context);
// 직군별 활성 에이전트도 같이 — 파이프라인 에디터가 "직군 → 담당자"
// cascading dropdown을 채울 때 이 페이로드만 보고 그릴 수 있게.
const { listActiveAgentsByCategory } = await import('./features/company');
const byCategory = listActiveAgentsByCategory(state);
// CompanyAgentDef를 통째로 보내는 대신 UI에 필요한 필드만 추려서.
const slimByCategory: Record<string, Array<{ id: string; name: string; emoji: string }>> = {};
for (const [cat, defs] of Object.entries(byCategory)) {
slimByCategory[cat] = defs.map((d) => ({ id: d.id, name: d.name, emoji: d.emoji }));
}
// 템플릿 카탈로그 — 가벼운 메타데이터만 (stages는 stamp 시점에 한 번
// 더 요청). UI는 dropdown 옵션 텍스트만 필요.
const templates = PIPELINE_TEMPLATES.map((t) => ({
templateId: t.templateId,
name: t.name,
description: t.description,
stageCount: t.stages.length,
suggestedPipelineId: t.suggestedPipelineId,
suggestedPipelineName: t.suggestedPipelineName,
}));
this._view.webview.postMessage({
type: 'companyPipelines',
value: {
pipelines: state.pipelines ?? {},
activePipelineId: state.activePipelineId ?? null,
roleCategoryLabels: ROLE_CATEGORY_LABELS,
roleCategoryOrder: ROLE_CATEGORY_ORDER,
activeAgentsByCategory: slimByCategory,
templates,
},
});
}
/** Send the chip state (active flag + agent count + name) to the webview. */
async _sendCompanyStatus(): Promise<void> {
if (!this._view) return;
const state = readCompanyState(this._context);
this._view.webview.postMessage({
type: 'companyStatus',
value: {
enabled: state.enabled,
companyName: state.companyName,
summary: summarizeForChip(state),
activeAgentIds: state.activeAgentIds,
modelOverrides: state.modelOverrides,
},
});
}
/**
* Push the full agent catalogue when the manage panel opens. Each entry
* carries both the *default* (from `agents.ts`) and *override* (from
* globalState) fields so the UI can show the user what they've edited,
* gray out unchanged fields, and offer a Reset button per agent.
*/
async _sendCompanyAgents(): Promise<void> {
if (!this._view) return;
const state = readCompanyState(this._context);
const cfg = getConfig();
const globalWeight = cfg.knowledgeMixSecondBrainWeight ?? 50;
// Built-ins first (insertion order from agents.ts), then user-added
// customs in their own order. `custom: true` lets the UI render a
// delete button differently for user-added entries.
const hiddenSet = new Set(state.hiddenBuiltinIds ?? []);
const builtinIds = COMPANY_AGENT_ORDER.filter((id) => !!COMPANY_AGENTS[id] && !hiddenSet.has(id));
const customIds = state.customAgents ? Object.keys(state.customAgents) : [];
const orderedIds = [...builtinIds, ...customIds];
// 파이프라인에서 사용 중인 에이전트 id별로 사용처(파이프라인 이름) 목록을 미리 계산.
// 카드의 삭제 버튼 비활성화 + tooltip에 사용 중인 파이프라인 이름을 노출하는 데 사용.
const pipelineUsage: Record<string, string[]> = {};
for (const p of Object.values(state.pipelines ?? {})) {
for (const s of p.stages) {
if (!s.agentId) continue;
(pipelineUsage[s.agentId] = pipelineUsage[s.agentId] ?? []).push(p.name || p.id);
}
}
const renderEntry = (id: string) => {
const builtin = COMPANY_AGENTS[id];
const custom = state.customAgents?.[id];
const baseDef = builtin ?? custom;
if (!baseDef) return null;
// 직군 override 적용된 effective def. 카드의 드롭다운이 옳은 선택값을
// 보이려면 override 결과를 보내야 한다.
const effective = resolveAgent(state, id) ?? baseDef;
const isCustom = !builtin;
const override = state.promptOverrides[id] || {};
const kmOverride = state.knowledgeMixOverrides[id];
const hasKmOverride = typeof kmOverride === 'number';
const roleOverride = state.roleCategoryOverrides?.[id];
const displayOv = state.displayOverrides?.[id] || {};
return {
id,
name: effective.name,
role: effective.role,
emoji: effective.emoji,
color: effective.color,
alwaysOn: !!effective.alwaysOn,
custom: isCustom,
active: id === 'ceo' || state.activeAgentIds.includes(id),
modelOverride: state.modelOverrides[id] || '',
defaultTagline: baseDef.tagline,
defaultSpecialty: baseDef.specialty,
defaultPersona: baseDef.persona || '',
tagline: override.tagline || baseDef.tagline,
specialty: override.specialty || baseDef.specialty,
persona: override.persona || baseDef.persona || '',
personaOverridden: !!override.persona,
specialtyOverridden: !!override.specialty,
taglineOverridden: !!override.tagline,
// 디스플레이 override — Edit 폼이 default vs current를 비교해
// dirty 표시할 수 있도록 baseDef의 원본 값도 같이 보낸다.
defaultName: baseDef.name,
defaultRole: baseDef.role,
defaultEmoji: baseDef.emoji,
defaultColor: baseDef.color,
nameOverridden: !!displayOv.name,
roleOverridden: !!displayOv.role,
emojiOverridden: !!displayOv.emoji,
colorOverridden: !!displayOv.color,
// 직군: effective(override 반영) + def 기본값 + override 플래그
roleCategory: effective.roleCategory,
defaultRoleCategory: baseDef.roleCategory,
roleCategoryOverridden: !!roleOverride && roleOverride !== baseDef.roleCategory,
knowledgeMixOverride: hasKmOverride ? kmOverride : null,
effectiveKnowledgeMixWeight: hasKmOverride ? kmOverride : globalWeight,
// 파이프라인 사용 현황. 비어 있으면 삭제 가능, 한 개라도 있으면 UI에서
// 삭제 버튼을 disabled로 처리하고 tooltip에 사용 중인 파이프라인을 노출.
usedInPipelines: pipelineUsage[id] ? [...new Set(pipelineUsage[id])] : [],
};
};
const agents = orderedIds.map(renderEntry).filter((x): x is NonNullable<ReturnType<typeof renderEntry>> => !!x);
// 숨김 처리된 빌트인 — 사용자에게 "복원" 옵션을 제공하기 위해 한 줄 메타로 같이 보낸다.
const hiddenBuiltins = (state.hiddenBuiltinIds ?? [])
.map((id) => {
const def = COMPANY_AGENTS[id];
if (!def) return null;
return { id, name: def.name, role: def.role, emoji: def.emoji };
})
.filter((x): x is { id: string; name: string; role: string; emoji: string } => !!x);
this._view.webview.postMessage({
type: 'companyAgents',
value: {
companyName: state.companyName,
globalKnowledgeMixWeight: globalWeight,
agents,
hiddenBuiltins,
// 직군 라벨 사전 + 표시 순서. 웹뷰는 enum 값을 모르므로
// 백엔드가 정한 라벨/순서를 같이 보내 UI 일관성을 유지.
roleCategoryLabels: ROLE_CATEGORY_LABELS,
roleCategoryOrder: ROLE_CATEGORY_ORDER,
},
});
}
/**
* Drive one full company turn. Caller is the chat handler; it's already
* persisted the user message and started a streaming bubble. We feed
* progress events back as `companyTurnUpdate` messages so the same bubble
* fills in as each agent finishes.
*/
async _runCompanyTurn(
userPrompt: string,
resumeTimestamp?: string,
pipelineIdOverride?: string,
requirementContract?: import('./features/company').RequirementContract,
): Promise<void> {
const cfg = getConfig();
const ai = new AIService();
// plan-ready / report-done 이벤트를 가로채 직전 turn 요약을 캐시에
// 저장. 다음 메시지의 intent classifier가 "이건 followup인가?" 판정에
// 사용한다. plan-ready로 brief를, report-done으로 보고서 끝부분을
// 잡아낸다 — turn이 중간 abort되면 plan만 남고 reportTail은 비어
// 있게 되는데, 그 상태로도 followup 매칭에는 충분히 도움된다.
let stagingBrief = '';
const emit = (event: CompanyTurnEvent) => {
this._view?.webview.postMessage({ type: 'companyTurnUpdate', value: event });
if (event.phase === 'plan-ready') {
stagingBrief = event.plan?.brief || '';
} else if (event.phase === 'report-done') {
const tail = (event.report || '').trim().slice(-600);
this._lastCompanyTurnSummary = {
brief: stagingBrief,
reportTail: tail,
finishedAt: Date.now(),
};
}
// Pixel Office hub — 같은 이벤트를 *추가로* read-only 변환. dispatcher
// 흐름엔 영향 없음(post 호출만 함).
try { this.pixelOfficeOnTurnEvent(event); } catch { /* never break the turn */ }
};
// Fresh AbortController per turn — the Stop button routes through
// `abortCompanyTurn()` to fire `.abort()`. The dispatcher checks
// `signal.aborted` between phases and short-circuits cleanly.
const abort = new AbortController();
this._companyAbort = abort;
try {
const deps: DispatcherDeps = {
context: this._context,
ai,
defaultModel: cfg.defaultModel || 'gemma4:e2b',
// Knowledge Mix wiring so company specialists *also* see the
// user's Second Brain — same global default + per-agent
// override semantics the chat path uses. Without this the
// Knowledge Mix slider had no effect on company turns.
globalKnowledgeMixWeight: cfg.knowledgeMixSecondBrainWeight ?? 50,
brainFileBaseline: cfg.memoryLongTermFiles ?? 6,
// Hand the dispatcher a thunk into ConnectAI's action-tag
// executor so specialist outputs like `<create_file>` actually
// hit disk. Without this, agents would *claim* to create
// files while nothing happened — the exact bug we just fixed.
executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text),
signal: abort.signal,
onEvent: emit,
// 승인 게이트 bridge — dispatcher가 호출하면 Promise를 만들어
// resolver를 _pendingApprovals에 보관 후 await. 사용자가 카드 버튼을
// 누르면 chatHandlers가 resolveApprovalGate(stageId, decision)을 호출
// 하고 그 resolve가 이 await을 풀어준다.
awaitApproval: ({ stageId }: { stageId: string; stageLabel: string }) =>
new Promise<ApprovalDecision>((resolve) => {
if (abort.signal.aborted) {
resolve({ kind: 'abort' });
return;
}
this._pendingApprovals.set(stageId, resolve);
}),
// 이번 turn 한정 pipeline override. chatHandlers가 의도 분류기
// 추천 또는 사용자 키워드 detection 결과를 채워서 넘긴다.
pipelineIdOverride,
// Intent Alignment에서 도출된 사용자 합의 contract. Phase D에서
// planner / specialist / reviewer prompt 모두에 주입됨. 없으면
// legacy 동작 (alignment 단계 자체를 거치지 않은 경우 또는 사용자가
// off 모드로 설정한 경우).
requirementContract,
};
// 일반 새 turn vs 재개 turn 분기. 재개 시 _resume.json에서 plan + cursor를
// 복원해 그 다음 stage부터 dispatch가 이어진다. resumeCompanyTurn이 null을
// 돌려주면(파일 없음·이미 완료 등) 사용자에게 알리고 종료.
if (resumeTimestamp) {
const result = await resumeCompanyTurn(resumeTimestamp, deps);
if (!result) {
this._view?.webview.postMessage({
type: 'error',
value: '재개 가능한 세션 정보를 찾지 못했습니다 (이미 완료되었거나 파일이 손상되었을 수 있습니다).',
});
}
} else {
await runCompanyTurn(userPrompt, deps);
}
} catch (e: any) {
logError('company.runTurn: unexpected failure.', { error: e?.message ?? String(e) });
this._view?.webview.postMessage({
type: 'error',
value: `1인 기업 모드 실행 실패: ${e?.message ?? e}`,
});
} finally {
if (this._companyAbort === abort) this._companyAbort = undefined;
// The webview's send button is locked into the "generating" state
// when the user submits; it only unlocks on `streamEnd`. The
// normal chat path posts that from inside AgentExecutor, but
// the company turn never touches AgentExecutor, so we have to
// post it ourselves here — otherwise the input stays disabled
// with the red Stop button after the round completes.
this._view?.webview.postMessage({ type: 'streamEnd' });
void this._sendReadyStatus();
// turn이 끝났으면(완료든 abort든) resume 가능 세션 목록을 새로 푸시 —
// 방금 abort된 세션이 곧장 목록에 떠야 하므로.
void this._sendCompanyResumable();
}
}
/**
* Webview에 "이어서 진행할 수 있는 세션" 목록을 push. 관리 패널이 열릴 때와 turn이
* 끝날 때마다 호출됨. 빈 목록도 그대로 보내서 UI가 섹션을 자동으로 숨길 수 있게 함.
*/
async _sendCompanyResumable(): Promise<void> {
if (!this._view) return;
try {
const items = listResumableSessions(this._context).map((s) => ({
timestamp: s.timestamp,
userPrompt: s.userPrompt.slice(0, 200),
pipelineId: s.pipelineId,
pipelineName: s.pipelineId
? (readCompanyState(this._context).pipelines?.[s.pipelineId]?.name ?? s.pipelineId)
: null,
completedCount: s.agentOutputs.length,
totalCount: s.plan.tasks.length,
status: s.status,
abortReason: s.abortReason ?? '',
lastUpdatedAt: s.lastUpdatedAt,
startedAt: s.startedAt,
}));
this._view.webview.postMessage({
type: 'companyResumable',
value: { items },
});
} catch (e: any) {
logError('company._sendCompanyResumable failed.', { error: e?.message ?? String(e) });
}
}
/** Open the architecture doc in editor group 2. */
async _openArchitectureDoc(): Promise<void> {
const p = this._getActiveChronicleProject();
if (!p || !p.architectureDocPath) return;
try {
const doc = await vscode.workspace.openTextDocument(p.architectureDocPath);
await vscode.window.showTextDocument(doc, {
viewColumn: vscode.ViewColumn.Two,
preview: false,
});
} catch (e: any) {
vscode.window.showErrorMessage(`Architecture doc 열기 실패: ${e?.message ?? e}`);
}
}
/**
* Register a debounced watcher over the project root. Only structural
* changes regen the doc — the signature hash decides whether to write.
* Files inside node_modules / out / dist are filtered by the glob to keep
* the noise floor sane during normal development.
*/
private _registerArchitectureWatcher(profile: ProjectProfile): void {
if (!profile.projectRoot) return;
if (this._archWatchedProjectId === profile.projectId && this._archWatcher) return;
this._disposeArchitectureWatcher();
if (profile.architectureAutoUpdate === false) {
this._archWatchedProjectId = profile.projectId;
return;
}
const pattern = new vscode.RelativePattern(
profile.projectRoot,
'{package.json,tsconfig.json,README.md,src/**,media/**,docs/**,tests/**,core_py/**,lib/**,app/**}'
);
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
const onChange = () => this._scheduleArchitectureRefresh();
watcher.onDidCreate(onChange);
watcher.onDidDelete(onChange);
watcher.onDidChange(onChange);
this._archWatcher = watcher;
this._archWatchedProjectId = profile.projectId;
logInfo('architecture: watcher registered.', { projectId: profile.projectId, root: profile.projectRoot });
}
private _disposeArchitectureWatcher(): void {
try { this._archWatcher?.dispose(); } catch { /* noop */ }
this._archWatcher = undefined;
if (this._archWatchDebounce) { clearTimeout(this._archWatchDebounce); this._archWatchDebounce = undefined; }
this._archWatchedProjectId = undefined;
}
private _scheduleArchitectureRefresh(): void {
if (this._archWatchDebounce) clearTimeout(this._archWatchDebounce);
// 6 s debounce: long enough that a "save file" burst settles into one
// regen, short enough that the chip's "updated 2m ago" badge stays
// believable.
this._archWatchDebounce = setTimeout(async () => {
const profile = this._getActiveChronicleProject();
if (!profile || !profile.projectRoot || profile.architectureAutoUpdate === false) return;
try {
// Cheap signature check first — most file events don't change shape.
const scan = scanProject(profile.projectRoot, profile.projectName);
if (scan.signature === profile.architectureLastScanSignature) return;
await this._refreshArchitecture();
} catch (e: any) {
logError('architecture: auto-refresh failed.', { error: e?.message ?? String(e) });
}
}, 6000);
}
_getActiveChronicleProject(): ProjectProfile | null {
const projects = this._getChronicleProjects();
if (projects.length === 0) return null;
const activeId = this._context.globalState.get<string>(SidebarChatProvider.activeChronicleProjectStateKey, '');
return projects.find(project => project.projectId === activeId) || projects[0];
}
async _sendChronicleProjects() {
if (!this._view) return;
const projects = this._getChronicleProjects();
const active = this._getActiveChronicleProject();
this._view.webview.postMessage({
type: 'chronicleProjects',
value: {
activeProjectId: active?.projectId || '',
projects: projects.map(project => ({
id: project.projectId,
name: project.projectName,
root: project.projectRoot || '',
recordRoot: project.recordRoot,
description: project.description || ''
}))
}
});
}
async _createChronicleProject() {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
const defaultName = workspaceRoot ? path.basename(workspaceRoot) : 'New Project';
const projectName = await vscode.window.showInputBox({
prompt: 'Project name for Chronicle records',
value: defaultName,
validateInput: (value) => value.trim() ? null : 'Project name is required.'
});
if (!projectName) return;
const description = await vscode.window.showInputBox({
prompt: 'One-line project description',
value: 'Project planning, decisions, development logs, and bug records.'
});
if (description === undefined) return;
const projectRoot = await vscode.window.showInputBox({
prompt: 'Project root path',
value: workspaceRoot,
validateInput: (value) => value.trim() ? null : 'Project root is required.'
});
if (!projectRoot) return;
const defaultRecordRoot = path.join(projectRoot.trim(), 'docs', 'records', projectName.trim());
const recordRoot = await vscode.window.showInputBox({
prompt: 'Markdown record folder path',
value: defaultRecordRoot,
validateInput: (value) => value.trim() ? null : 'Record folder path is required.'
});
if (!recordRoot) return;
const corePurpose = await vscode.window.showInputBox({
prompt: 'Core project purpose or guardrail',
value: 'Keep project knowledge traceable through Markdown records.'
});
if (corePurpose === undefined) return;
const detailChoice = await vscode.window.showQuickPick([
{ label: 'standard', description: 'Request, question intent, decisions, planning, development, and bugs' },
{ label: 'simple', description: 'Request summary, decisions, and implementation result' },
{ label: 'detailed', description: 'Standard records plus alternatives, lessons, and retrospectives' }
], {
placeHolder: 'Chronicle record detail level'
});
if (!detailChoice) return;
const now = new Date().toISOString();
const projects = this._getChronicleProjects();
const idBase = this._slugify(projectName.trim());
let projectId = idBase;
let suffix = 2;
while (projects.some(project => project.projectId === projectId)) {
projectId = `${idBase}-${suffix++}`;
}
const profile: ProjectProfile = {
projectId,
projectName: projectName.trim(),
projectRoot: projectRoot.trim(),
recordRoot: recordRoot.trim(),
description: description.trim(),
corePurpose: corePurpose.trim(),
targetUsers: ['Project developer'],
avoidDirections: ['Do not mix records across projects.', 'Do not tightly couple records to core agent execution.'],
detailLevel: detailChoice.label as ProjectProfile['detailLevel'],
createdAt: now,
updatedAt: now
};
this._chronicle.ensureProject(profile);
const nextProjects = [...projects.filter(project => project.projectId !== profile.projectId), profile];
await this._putChronicleProjects(nextProjects);
await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, profile.projectId);
await this._sendChronicleProjects();
this.injectSystemMessage(`**[Designer Project Created]** ${profile.projectName}\n\`${profile.recordRoot}\``);
}
async _setActiveChronicleProject(projectId: string) {
if (!projectId || projectId === 'new') {
await this._createChronicleProject();
return;
}
const target = this._getChronicleProjects().find(project => project.projectId === projectId);
if (!target) return;
await this._context.globalState.update(SidebarChatProvider.activeChronicleProjectStateKey, target.projectId);
await this._sendChronicleProjects();
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Designer Project Selected]** ${target.projectName}\n\`${target.recordRoot}\``);
}
async _openChronicleFolder() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('No Chronicle project is selected.');
return;
}
try {
this._chronicle.ensureProject(profile);
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(profile.recordRoot));
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to open Chronicle folder: ${err.message}`);
}
}
async _sendChronicleRecords() {
if (!this._view) return;
const profile = this._getActiveChronicleProject();
if (!profile) {
this._view.webview.postMessage({ type: 'chronicleRecords', value: [] });
return;
}
try {
const records = this._chronicle.listRecords(profile).map(record => ({
section: record.section,
fileName: record.fileName,
path: record.filePath,
relativePath: record.relativePath,
updatedAt: record.updatedAt
}));
this._view.webview.postMessage({ type: 'chronicleRecords', value: records });
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to list Chronicle records: ${err.message}`);
}
}
async _openChronicleRecord(recordPath: string) {
const profile = this._getActiveChronicleProject();
if (!profile || !recordPath) {
vscode.window.showWarningMessage('Select a Chronicle record first.');
return;
}
const root = path.resolve(profile.recordRoot);
const target = path.resolve(recordPath);
if (!target.startsWith(`${root}${path.sep}`) || path.extname(target) !== '.md') {
vscode.window.showErrorMessage('Selected Chronicle record path is not valid.');
return;
}
if (!fs.existsSync(target)) {
vscode.window.showErrorMessage('Selected Chronicle record no longer exists.');
await this._sendChronicleRecords();
return;
}
await openInEditorGroup(target);
}
async _writeChroniclePlanningFromCurrentChat() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
return;
}
const history = this._agent.getHistory();
const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
const featureName = await vscode.window.showInputBox({
prompt: 'Feature name for the planning document',
value: this._summarizeForTitle(latestUser || 'Project Chronicle Feature')
});
if (!featureName) return;
try {
const createdAt = new Date().toISOString();
const result = this._chronicle.writePlanning(profile, {
featureName: featureName.trim(),
purpose: 'Record the reason, scope, direction, and success criteria before implementation.',
background: this._summarizeTextForWiki(latestAssistant || latestUser),
userIntent: this._summarizeTextForWiki(latestUser),
sourceRequest: latestUser || 'No user request captured in the current chat.',
scope: [
'Create a project-specific planning record.',
'Capture user intent and implementation direction.',
'Keep the record independent from chat execution internals.'
],
outOfScope: [
'Full automatic transcript capture.',
'External database integration.',
'Git automation.'
],
developmentDirection: 'Use Project Chronicle as a low-dependency Markdown record layer.',
dependencyStrategy: 'Use local filesystem writes through the independent projectChronicle module.',
expectedValue: 'Future work can understand why this feature exists and what decisions shaped it.',
successCriteria: [
'The planning document is created under the selected project record folder.',
'The document includes user intent, scope, out-of-scope items, and success criteria.'
],
developerInstruction: 'Use this document as the implementation guardrail for the next development step.',
createdAt
});
this._chronicle.appendTimeline(profile, [`Planning record created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle planning saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Planning Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write planning record: ${err.message}`);
}
}
async _writeChronicleDiscussionFromCurrentChat() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
return;
}
const history = this._agent.getHistory();
const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
const title = await vscode.window.showInputBox({
prompt: 'Discussion title',
value: this._summarizeForTitle(latestUser || 'Project Discussion')
});
if (!title) return;
const question = await vscode.window.showInputBox({
prompt: 'AI question to record (optional)',
value: ''
});
if (question === undefined) return;
let questions: any[] = [];
if (question.trim()) {
const reason = await vscode.window.showInputBox({
prompt: 'Why was this question asked?',
value: 'To avoid writing records to the wrong project or making an unclear design decision.'
});
if (reason === undefined) return;
const impact = await vscode.window.showInputBox({
prompt: 'How does this question affect the decision?',
value: 'It determines the correct project context, scope, or implementation path.'
});
if (impact === undefined) return;
questions = [{
question: question.trim(),
reason: reason.trim(),
expectedInformation: 'Information needed to clarify project context, scope, or decision direction.',
impactOnDecision: impact.trim()
}];
}
try {
const createdAt = new Date().toISOString();
const result = this._chronicle.writeDiscussion(profile, {
title: title.trim(),
userRequest: this._summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'),
interpretedIntent: 'Capture the discussion as a reusable project knowledge record instead of leaving it only in chat history.',
questions,
discussions: [
this._summarizeTextForWiki(latestAssistant || latestUser || 'No discussion detail captured yet.')
],
decisions: [],
createdAt
});
this._chronicle.appendTimeline(profile, [`Discussion record created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle discussion saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Discussion Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write discussion record: ${err.message}`);
}
}
async _writeChronicleDecisionFromInput() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
return;
}
const title = await vscode.window.showInputBox({
prompt: 'Decision title',
value: 'Use independent Markdown record module'
});
if (!title) return;
const decision = await vscode.window.showInputBox({
prompt: 'Decision',
value: 'Implement this behavior as an independent Project Chronicle module.'
});
if (decision === undefined) return;
const reason = await vscode.window.showInputBox({
prompt: 'Decision reason',
value: 'To reduce coupling and keep project records portable.'
});
if (reason === undefined) return;
const alternatives = await vscode.window.showInputBox({
prompt: 'Rejected alternatives (comma-separated)',
value: 'Integrate with Second Brain, integrate directly into Agent execution'
});
if (alternatives === undefined) return;
try {
const createdAt = new Date().toISOString();
const adrNumber = this._chronicle.nextAdrNumber(profile);
const result = this._chronicle.writeDecision(profile, {
title: title.trim(),
status: 'accepted',
context: 'A project record needs to capture not only what changed, but why the direction was chosen.',
decision: decision.trim(),
reason: reason.trim(),
alternatives: alternatives.split(',').map(item => item.trim()).filter(Boolean),
consequences: [
'Records can evolve independently from chat and agent internals.',
'Future automation can emit chronicle events without owning the core execution path.'
],
createdAt
}, adrNumber);
this._chronicle.appendTimeline(profile, [`Decision record created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle decision saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Decision Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write decision record: ${err.message}`);
}
}
async _writeChronicleDevelopmentFromCurrentChat() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
return;
}
const history = this._agent.getHistory();
const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
const featureName = await vscode.window.showInputBox({
prompt: 'Feature name for the development log',
value: this._summarizeForTitle(latestUser || 'Implementation Log')
});
if (!featureName) return;
try {
const createdAt = new Date().toISOString();
const result = this._chronicle.writeDevelopmentLog(profile, {
featureName: featureName.trim(),
purpose: 'Record the actual implementation outcome for later maintenance.',
implementationSummary: this._summarizeTextForWiki(latestAssistant || 'Implementation summary was not captured from chat yet.'),
architecture: 'Project Chronicle records are written through an independent Markdown module.',
changedFiles: ['Capture exact changed files after verification.'],
dependencyNotes: 'Keep dependencies limited to TypeScript, Node fs/path, and VS Code extension APIs.',
bugs: [],
lessons: [
'Write implementation notes as soon as a stable development step finishes.',
'Keep generated records project-specific.'
],
createdAt
});
this._chronicle.appendTimeline(profile, [`Development log created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle development log saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Development Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write development record: ${err.message}`);
}
}
async _writeChronicleBugFromInput() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
return;
}
const title = await vscode.window.showInputBox({
prompt: 'Bug title',
value: 'record-generation-issue'
});
if (!title) return;
const symptom = await vscode.window.showInputBox({
prompt: 'Bug symptom',
value: 'Describe what failed or looked wrong.'
});
if (symptom === undefined) return;
const cause = await vscode.window.showInputBox({
prompt: 'Bug cause',
value: 'Cause is not confirmed yet.'
});
if (cause === undefined) return;
const fix = await vscode.window.showInputBox({
prompt: 'Fix',
value: 'Describe the fix or mitigation.'
});
if (fix === undefined) return;
try {
const createdAt = new Date().toISOString();
const bugNumber = this._chronicle.nextBugNumber(profile);
const result = this._chronicle.writeBug(profile, {
title: title.trim(),
symptom: symptom.trim(),
cause: cause.trim(),
fix: fix.trim(),
prevention: 'Validate project selection, record path, and write permissions before generating files.',
createdAt
}, bugNumber);
this._chronicle.appendTimeline(profile, [`Bug record created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle bug record saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Bug Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write bug record: ${err.message}`);
}
}
async _writeChronicleRetrospectiveFromInput() {
const profile = this._getActiveChronicleProject();
if (!profile) {
vscode.window.showWarningMessage('Select or create a Designer project first.');
return;
}
const title = await vscode.window.showInputBox({
prompt: 'Retrospective title',
value: 'Project Chronicle Guard iteration'
});
if (!title) return;
const summary = await vscode.window.showInputBox({
prompt: 'Work summary',
value: 'Completed an incremental development step and recorded the outcome.'
});
if (summary === undefined) return;
const wentWell = await vscode.window.showInputBox({
prompt: 'What went well? (comma-separated)',
value: 'Kept the feature independent, Generated Markdown records, Preserved project context'
});
if (wentWell === undefined) return;
const toImprove = await vscode.window.showInputBox({
prompt: 'What should improve? (comma-separated)',
value: 'More automatic question intent capture, Richer record editing UI'
});
if (toImprove === undefined) return;
const nextActions = await vscode.window.showInputBox({
prompt: 'Next actions (comma-separated)',
value: 'Add tests, Improve Designer UI, Add event-based record capture'
});
if (nextActions === undefined) return;
try {
const createdAt = new Date().toISOString();
const result = this._chronicle.writeRetrospective(profile, {
title: title.trim(),
summary: summary.trim(),
wentWell: wentWell.split(',').map(item => item.trim()).filter(Boolean),
toImprove: toImprove.split(',').map(item => item.trim()).filter(Boolean),
nextActions: nextActions.split(',').map(item => item.trim()).filter(Boolean),
createdAt
});
this._chronicle.appendTimeline(profile, [`Retrospective created: ${result.relativePath}`], createdAt);
vscode.window.showInformationMessage(`Chronicle retrospective saved: ${result.relativePath}`);
await this._sendChronicleRecords();
this.injectSystemMessage(`**[Chronicle Retrospective Saved]** \`${result.filePath}\``);
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to write retrospective record: ${err.message}`);
}
}
async _autoWriteChronicleAfterPrompt() {
const history = this._agent.getHistory();
const latestUser = [...history].reverse().find(message => message.role === 'user')?.content || '';
const latestAssistant = [...history].reverse().find(message => message.role === 'assistant')?.content || '';
const profile = this._getChronicleProjectForConversation(latestUser) || this._getActiveChronicleProject();
if (!profile) return;
const recordType = this._inferAutoChronicleRecordType(latestUser, latestAssistant);
if (!recordType) return;
const signature = [
profile.projectId,
recordType,
this._summarizeTextForWiki(latestUser).slice(0, 240),
this._summarizeTextForWiki(latestAssistant).slice(0, 240)
].join('|');
const lastSignature = this._context.globalState.get<string>(SidebarChatProvider.lastAutoChronicleSignatureStateKey, '');
if (signature === lastSignature) return;
try {
this._chronicle.ensureProject(profile);
const createdAt = new Date().toISOString();
const title = this._summarizeForTitle(latestUser || latestAssistant || 'Project Chronicle Auto Record');
const summary = this._summarizeTextForWiki(latestAssistant || latestUser);
let result;
if (recordType === 'bug') {
const bugNumber = this._chronicle.nextBugNumber(profile);
result = this._chronicle.writeBug(profile, {
title,
symptom: this._summarizeTextForWiki(latestUser || 'Issue was detected during the conversation.'),
cause: 'Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.',
fix: summary,
prevention: 'Keep automatic records tied to the active project and verify the relevant test or reproduction path.',
createdAt
}, bugNumber);
} else if (recordType === 'planning') {
result = this._chronicle.writePlanning(profile, {
featureName: title,
purpose: 'Capture the current planning or architecture direction before implementation continues.',
background: summary,
userIntent: this._summarizeTextForWiki(latestUser),
sourceRequest: latestUser || 'No user request captured in the current chat.',
scope: ['Continue from the active project conversation.', 'Use the selected project record folder automatically.'],
outOfScope: ['Manual record type selection.', 'Blocking the user with record-writing prompts.'],
developmentDirection: summary,
dependencyStrategy: 'Prefer existing project modules and local Markdown records.',
expectedValue: 'Future work can resume with the latest project intent and reasoning preserved.',
successCriteria: ['The record is saved automatically after a meaningful project turn.', 'The record stays under the active project.'],
developerInstruction: 'Use this record as lightweight context for the next development or review pass.',
createdAt
});
} else if (recordType === 'decision') {
const adrNumber = this._chronicle.nextAdrNumber(profile);
result = this._chronicle.writeDecision(profile, {
title,
status: 'accepted',
context: this._summarizeTextForWiki(latestUser),
decision: summary,
reason: 'Captured automatically because the conversation contained decision-oriented language.',
alternatives: [],
consequences: ['Future prompts should treat this as project context unless the user changes direction.'],
createdAt
}, adrNumber);
} else if (recordType === 'development') {
result = this._chronicle.writeDevelopmentLog(profile, {
featureName: title,
purpose: 'Record the implementation or verification outcome from the current conversation.',
implementationSummary: summary,
architecture: 'Captured automatically from the assistant response and active project context.',
changedFiles: this._extractChangedFilesFromText(latestAssistant),
dependencyNotes: 'No new dependency note was captured automatically.',
bugs: [],
lessons: ['Automatic project records should be generated in the background when the turn contains durable project knowledge.'],
createdAt
});
} else {
result = this._chronicle.writeDiscussion(profile, {
title,
userRequest: this._summarizeTextForWiki(latestUser || 'No user request captured in the current chat.'),
interpretedIntent: 'Capture a meaningful project discussion automatically instead of requiring manual record selection.',
questions: [],
discussions: [summary],
decisions: [],
createdAt
});
}
this._chronicle.appendTimeline(profile, [`Auto ${recordType} record created: ${result.relativePath}`], createdAt);
await this._context.globalState.update(SidebarChatProvider.lastAutoChronicleSignatureStateKey, signature);
await this._sendChronicleRecords();
vscode.window.setStatusBarMessage(`Astra: Chronicle auto-saved ${recordType}`, 3500);
} catch (err: any) {
logError('Automatic Chronicle record write failed.', { error: err?.message || String(err), recordType });
}
}
_getChronicleProjectForConversation(text: string): ProjectProfile | null {
const projectPath = this._extractLocalProjectPath(text);
if (!projectPath) return null;
const projects = this._getChronicleProjects();
const resolvedPath = path.resolve(projectPath);
const existing = projects.find(project => {
const root = project.projectRoot ? path.resolve(project.projectRoot) : '';
const recordRoot = path.resolve(project.recordRoot);
return root === resolvedPath || recordRoot.startsWith(`${resolvedPath}${path.sep}`);
});
if (existing) return existing;
const projectName = path.basename(resolvedPath) || 'Current Project';
const now = new Date().toISOString();
return {
projectId: this._slugify(projectName),
projectName,
projectRoot: resolvedPath,
recordRoot: path.join(resolvedPath, 'docs', 'records', projectName),
description: 'Auto-detected from the local project path in the conversation.',
corePurpose: 'Capture project direction, architecture discussion, decisions, and development notes as Markdown.',
targetUsers: ['Project developer'],
avoidDirections: ['Do not mix records across projects.'],
detailLevel: 'standard',
createdAt: now,
updatedAt: now
};
}
_extractLocalProjectPath(text: string): string | null {
const match = text.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+/i);
if (!match) return null;
const candidate = match[0].replace(/[.,;:)\]]+$/, '');
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
return candidate;
}
} catch {
return null;
}
return null;
}
_inferAutoChronicleRecordType(userText: string, assistantText: string): 'planning' | 'discussion' | 'decision' | 'development' | 'bug' | null {
const combined = `${userText}\n${assistantText}`;
if (!combined.trim()) return null;
if (/(기록하지마|저장하지마|no\s+record|do\s+not\s+record)/i.test(combined)) return null;
if (!/(프로젝트|코드|아키텍처|설계|개선|수정|구현|테스트|검증|이슈|문제|버그|오류|끊김|결정|방향|기록|지식|review|architecture|implement|fix|bug|issue|test|decision)/i.test(combined)) {
return null;
}
if (/(버그|오류|에러|이슈|문제|끊김|안\s*됨|실패|bug|error|issue|failed|failure)/i.test(userText)) {
return 'bug';
}
if (/(수정 완료|개선 완료|구현 완료|패치|테스트.*통과|검증.*완료|변경.*파일|compile|jest|tsc|passed|implemented|fixed)/i.test(assistantText)) {
return 'development';
}
if (/(결정|확정|채택|방향은|하기로|하지 않기로|decision|decide|accepted)/i.test(combined)) {
return 'decision';
}
if (/(계획|설계|아키텍처|조사|방향|로드맵|mvp|planning|architecture|roadmap|design)/i.test(userText)) {
return 'planning';
}
if (/(개선|수정|구현|테스트|검증|패킹|compile|jest|tsc|implement|fix|test|verify)/i.test(combined)) {
return 'development';
}
return 'discussion';
}
_extractChangedFilesFromText(text: string): string[] {
const files = new Set<string>();
for (const match of text.matchAll(/`([^`\n]+\.(?:ts|tsx|js|jsx|json|md|css|html|py|yml|yaml))`/gi)) {
files.add(match[1].trim());
}
return files.size > 0 ? Array.from(files).slice(0, 12) : ['No explicit changed file list was captured automatically.'];
}
async _writeChronicleRecord(recordType: string) {
switch (recordType) {
case 'planning':
await this._writeChroniclePlanningFromCurrentChat();
break;
case 'discussion':
await this._writeChronicleDiscussionFromCurrentChat();
break;
case 'decision':
await this._writeChronicleDecisionFromInput();
break;
case 'development':
await this._writeChronicleDevelopmentFromCurrentChat();
break;
case 'bug':
await this._writeChronicleBugFromInput();
break;
case 'retrospective':
await this._writeChronicleRetrospectiveFromInput();
break;
default:
vscode.window.showWarningMessage('Select a Chronicle record type first.');
}
}
_getAgentsDir(): string {
// 1) Explicit config override (works on any OS — useful on Windows or for skills outside the workspace).
const configured = (vscode.workspace.getConfiguration('g1nation').get<string>('agentSkillsPath', '') || '').trim();
const expanded = configured.startsWith('~/') || configured === '~'
? path.join(os.homedir(), configured.slice(1).replace(/^[\\/]/, ''))
: configured;
if (expanded && path.isAbsolute(expanded)) {
if (!fs.existsSync(expanded)) {
try { fs.mkdirSync(expanded, { recursive: true }); } catch { /* fall through to workspace */ }
}
if (fs.existsSync(expanded)) return expanded;
}
// 2) Default: <workspace>/.agent/skills
const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders) {
const localPath = path.join(workspaceFolders[0].uri.fsPath, '.agent', 'skills');
if (!fs.existsSync(localPath)) {
fs.mkdirSync(localPath, { recursive: true });
}
return localPath;
}
return '';
}
async _sendAgentsList() {
if (!this._view) return;
const dir = this._getAgentsDir();
const agents = [];
if (dir && fs.existsSync(dir)) {
const files = fs.readdirSync(dir);
for (const f of files) {
if (f.endsWith('.md')) {
agents.push({ name: f.replace('.md', ''), path: path.join(dir, f) });
}
}
}
const lastPath = this._context.globalState.get<string>(SidebarChatProvider.lastAgentStateKey, 'none');
this._view.webview.postMessage({ type: 'agentsList', value: agents, selected: lastPath });
void this._sendReadyStatus();
}
async _handleProactiveSuggestion(context: string) {
if (!this._view) return;
let suggestion = '';
switch (context) {
case 'settings_exploration':
suggestion = '💡 **Tip:** 모델 설정을 최적화하여 답변 속도를 2배 이상 높일 수 있습니다. 설정에서 `Max Context Size`를 조정해보세요!';
break;
case 'brain_sync_exploration':
suggestion = '🧠 **Knowledge Sync:** 최근에 수정한 파일이 지식 베이스에 반영되지 않았나요? 지금 동기화 버튼을 눌러 최신 정보를 업데이트하세요.';
break;
case 'agent_selection_exploration':
suggestion = '🤖 **Agent Skills:** 특정 언어나 프레임워크에 특화된 에이전트 스킬을 선택하면 더 정확한 코드를 생성할 수 있습니다.';
break;
default:
suggestion = '💡 새로운 기능을 발견하셨나요? 궁금한 점이 있다면 언제든 물어보세요!';
}
this._view.webview.postMessage({ type: 'streamStart' });
this._view.webview.postMessage({ type: 'streamChunk', value: `\n\n> [!TIP]\n> ${suggestion}\n` });
this._view.webview.postMessage({ type: 'streamEnd' });
}
async _createAgent() {
const name = await vscode.window.showInputBox({
prompt: 'Name of the new Agent (e.g., frontend_expert)',
placeHolder: 'Agent name...'
});
if (!name) return;
const safeName = name.trim().replace(/[^a-zA-Z0-9_\-\u3131-\uD79D가-힣]/g, '_');
if (!safeName) return;
const dir = this._getAgentsDir();
if (!dir) {
vscode.window.showErrorMessage('Agent directory could not be determined.');
return;
}
const filePath = path.join(dir, `${safeName}.md`);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, `# Agent Persona: ${safeName}\n\nAdd your instructions here...\n`, 'utf8');
}
await openInEditorGroup(filePath);
await this._sendAgentsList();
}
async _sendAgentContent(agentPath: string) {
if (!this._view || !agentPath || agentPath === 'none') return;
if (fs.existsSync(agentPath)) {
const content = fs.readFileSync(agentPath, 'utf8');
const negativePrompt = this._context.globalState.get<string>(`negativePrompt:${agentPath}`, '');
this._view.webview.postMessage({
type: 'agentContent',
value: content,
negativePrompt: negativePrompt
});
await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, agentPath);
}
}
async _updateAgent(agentPath: string, content: string, negativePrompt?: string) {
if (!agentPath || agentPath === 'none') return;
try {
fs.writeFileSync(agentPath, content, 'utf8');
if (negativePrompt !== undefined) {
await this._context.globalState.update(`negativePrompt:${agentPath}`, negativePrompt);
}
vscode.window.showInformationMessage('Agent skill updated successfully.');
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to update agent: ${err.message}`);
}
}
async _deleteAgent(agentPath: string) {
if (!agentPath || agentPath === 'none') return;
try {
const agentsDir = path.resolve(this._getAgentsDir());
const targetPath = path.resolve(agentPath);
if (!targetPath.startsWith(`${agentsDir}${path.sep}`) || path.extname(targetPath) !== '.md') {
vscode.window.showErrorMessage('Selected agent skill path is not valid.');
return;
}
if (!fs.existsSync(targetPath)) {
await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, 'none');
await this._sendAgentsList();
return;
}
const confirm = await vscode.window.showWarningMessage(
`Delete agent skill "${path.basename(targetPath, '.md')}"?`,
{ modal: true },
'Delete Skill'
);
if (confirm !== 'Delete Skill') return;
fs.unlinkSync(targetPath);
await this._context.globalState.update(SidebarChatProvider.lastAgentStateKey, 'none');
await this._sendAgentsList();
this._view?.webview.postMessage({ type: 'agentDeleted' });
vscode.window.showInformationMessage('Agent skill deleted successfully.');
} catch (err: any) {
vscode.window.showErrorMessage(`Failed to delete agent: ${err.message}`);
}
}
async _handlePrompt(data: any) {
if (!this._view) return;
const { value, model, internet, files, agentFile, negativePrompt, designerGuard, secondBrainTrace, secondBrainTraceDebug, brainProfileId } = data;
this._currentNegativePrompt = negativePrompt || '';
const selectedBrainId = typeof brainProfileId === 'string' && brainProfileId && brainProfileId !== 'new'
? brainProfileId
: getActiveBrainProfile().id;
this._currentSessionBrainId = selectedBrainId;
let agentSkillContext = undefined;
// Per-agent model override: if the active agent has a pinned model in the
// knowledge map, it wins over the model the webview just sent. Falls back
// to the incoming `model` (which is the global default the user picked).
let effectiveModel: string = typeof model === 'string' ? model : '';
if (agentFile && agentFile !== 'none' && fs.existsSync(agentFile)) {
const fileContent = fs.readFileSync(agentFile, 'utf8');
// Guard: a freshly-created agent still has only the placeholder template
// ("# Agent Persona: …\n\nAdd your instructions here…"). Treating that as a real
// agent prompt just confuses the model — fall back to normal mode and tell the user.
const body = fileContent.replace(/^?#\s*Agent\s*Persona\s*:.*$/im, '').trim();
const isPlaceholder = !body || /^add your instructions here/i.test(body);
if (isPlaceholder) {
logInfo('Selected agent has no real instructions — running without agent mode.', { agentFile });
this._view?.webview.postMessage({ type: 'lmStudioError', value: '선택한 에이전트에 내용이 없습니다 — 에이전트 프롬프트를 작성한 뒤 다시 시도하세요. (이번 응답은 에이전트 없이 처리합니다)' });
} else {
agentSkillContext = fileContent;
// Merge in any external skill .md files the user has mapped to this agent. We concatenate
// into the same agentSkillContext blob so the rest of the pipeline (agent.ts, agent-mode
// override) treats them identically to the agent's own .md — no further changes needed.
try {
const entry = getOrCreateAgentEntry(agentFile);
const bundle = loadExternalSkills(entry.skillFolders);
const block = formatSkillsAsPromptBlock(bundle);
if (block) {
agentSkillContext = `${agentSkillContext.trim()}\n\n${block}`;
}
// Apply the per-agent model override, if any.
const pinned = entry.model?.trim();
if (pinned && pinned !== effectiveModel) {
logInfo('Per-agent model override applied.', {
agent: entry.name,
requested: effectiveModel,
pinned,
});
effectiveModel = pinned;
// Inform the webview so its UI can reflect the model that's actually in use.
this._view?.webview.postMessage({
type: 'agentModelOverride',
value: { agent: entry.name, model: pinned },
});
}
} catch (e: any) {
logError('External skill load failed.', { error: e?.message || String(e) });
}
}
}
const designerContext = designerGuard !== false ? this._buildDesignerGuardContext() : undefined;
// Project Architecture activation (Feature 2): if the user just said
// "나 X 프로젝트 진행할 거야" / mentioned an absolute path / etc., switch
// to that project's mode before assembling the prompt. Best-effort:
// failures here never block the actual answer.
if (typeof value === 'string' && value.trim().length > 0) {
try {
await this._tryActivateArchitectureFromText(value);
} catch (e: any) {
logError('architecture: intent detection failed.', { error: e?.message ?? String(e) });
}
}
// Re-resolve the active subproject from the currently-focused editor.
// Without this, switching between subprojects (e.g. ConnectAI →
// Datacollector) inside one VS Code window keeps loading the previous
// subproject's architecture into the prompt.
try {
await this._ensureActiveProjectForWorkspace();
} catch (e: any) {
logError('architecture: workspace resync failed.', { error: e?.message ?? String(e) });
}
const projectArchitectureContext = this._buildProjectArchitectureContext();
// [File Processing v2] 파일 타입별 분류 처리
let processedPrompt = value || '';
let imageFiles: any[] | undefined = undefined;
if (files && Array.isArray(files) && files.length > 0) {
const textContents: string[] = [];
const images: any[] = [];
for (const file of files) {
const name = file.name?.toLowerCase() || '';
const type = file.type || '';
if (name.endsWith('.pdf') || type === 'application/pdf') {
// PDF: 서버사이드 텍스트 추출 (pdf-parse v2 API) + Vision 폴백
let pdfTextOk = false;
try {
const { PDFParse } = require('pdf-parse');
const rawBuffer = Buffer.from(file.data, 'base64');
const uint8 = new Uint8Array(rawBuffer.buffer, rawBuffer.byteOffset, rawBuffer.byteLength);
const parser = new PDFParse(uint8);
await parser.load();
const textResult = await parser.getText();
const extracted = (typeof textResult === 'string' ? textResult : (textResult?.text || '')).trim();
const cleanText = extracted.replace(/\n*-- \d+ of \d+ --\n*/g, '\n').trim();
if (cleanText && cleanText.length > 30) {
textContents.push(`\n[PDF: ${file.name}]\n${cleanText}`);
logInfo(`PDF text extracted successfully.`, { fileName: file.name, chars: cleanText.length });
pdfTextOk = true;
}
// [Vision Fallback] 텍스트가 비어있으면 페이지 이미지 추출 -> Vision 모델에 전달
if (!pdfTextOk) {
logInfo(`PDF has no text layer. Extracting page screenshots for vision analysis.`, { fileName: file.name });
const screenshots = await parser.getScreenshot({ page: 1 });
if (screenshots?.pages && screenshots.pages.length > 0) {
const maxPages = Math.min(screenshots.pages.length, 8); // 메모리 보호: 최대 8페이지
for (let i = 0; i < maxPages; i++) {
const page = screenshots.pages[i];
if (page?.data) {
const pageBase64 = Buffer.from(page.data).toString('base64');
images.push({
name: `${file.name}_page${i + 1}.png`,
type: 'image/png',
data: pageBase64
});
}
}
textContents.push(`\n[PDF: ${file.name}]\n(이미지 기반 PDF ${screenshots.total}페이지 중 ${maxPages}페이지를 이미지로 추출하여 Vision 분석합니다. 각 페이지 이미지를 참조하여 문서의 내용을 상세히 분석하고 한국어로 정리하세요.)`);
logInfo(`PDF vision fallback: extracted ${maxPages} page screenshots.`, { fileName: file.name, totalPages: screenshots.total });
pdfTextOk = true; // Vision 분석으로 처리 완료
}
}
} catch (pdfError: any) {
logError(`PDF processing failed.`, { fileName: file.name, error: pdfError?.message || String(pdfError) });
}
// 최종 폴백: 텍스트도 없고 이미지 추출도 실패한 경우
if (!pdfTextOk) {
textContents.push(`\n[PDF: ${file.name}]\n(PDF 분석에 실패했습니다. 이 파일을 텍스트로 변환하여 다시 시도해주세요.)`);
}
} else if (
type.startsWith('text/') ||
type === 'application/json' ||
/\.(txt|md|csv|json|js|ts|jsx|tsx|html|css|py|java|rs|go|yaml|yml|xml|toml|sql|sh)$/i.test(name)
) {
// 텍스트 파일: base64 디코딩
try {
const decoded = Buffer.from(file.data, 'base64').toString('utf-8');
textContents.push(`\n[FILE: ${file.name}]\n\`\`\`\n${decoded}\n\`\`\``);
} catch (decodeError: any) {
logError(`Text file decode failed.`, { fileName: file.name, error: decodeError?.message });
textContents.push(`\n[FILE: ${file.name}]\n(디코딩 오류)`);
}
} else if (type.startsWith('image/')) {
// 이미지: 기존 vision 방식 유지
images.push(file);
} else {
// 미지원 타입: 파일명만 기록
textContents.push(`\n[ATTACHMENT: ${file.name}]\n(지원하지 않는 파일 형식: ${type || 'unknown'})`);
}
}
// 추출된 텍스트를 프롬프트에 주입
if (textContents.length > 0) {
processedPrompt = `${processedPrompt}\n\n--- 첨부 파일 내용 ---${textContents.join('\n')}`;
}
imageFiles = images.length > 0 ? images : undefined;
}
try {
await this._agent.handlePrompt(processedPrompt, effectiveModel || model, {
internetEnabled: internet,
visionContent: imageFiles,
agentSkillContext,
agentSkillFile: typeof agentFile === 'string' ? agentFile : undefined,
negativePrompt,
designerContext,
projectArchitectureContext: projectArchitectureContext || undefined,
secondBrainTraceEnabled: secondBrainTrace !== false,
secondBrainTraceDebug: !!secondBrainTraceDebug,
brainProfileId: selectedBrainId
});
} catch (error: any) {
logError('Prompt handling failed in sidebar provider.', { error: error?.message || String(error), promptPreview: summarizeText(value || '', 200) });
this._view.webview.postMessage({ type: 'error', value: error.message });
} finally {
void this._sendReadyStatus();
}
}
_buildDesignerGuardContext(): string {
return buildProjectChronicleGuardContext(this._getActiveChronicleProject());
}
async _sendModels(force: boolean = false) {
if (!this._view) return;
if (this._modelDiscoveryInFlight) {
logInfo('Model discovery already in progress, skipping.');
return;
}
this._modelDiscoveryInFlight = true;
try {
const config = getConfig();
const url = config.ollamaUrl;
let defaultModel = config.defaultModel;
let models: string[] = [];
let online = false;
const cache = this._modelsCache;
const cacheFresh = !!cache
&& cache.url === url
&& (Date.now() - cache.fetchedAt) < SidebarChatProvider.MODELS_CACHE_TTL_MS;
if (!force && cacheFresh && cache) {
models = cache.models.slice();
online = cache.online;
this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } });
} else {
const engine = resolveEngine(url); // 단일 엔진만
const modelsUrl = buildApiUrl(url, engine, 'models');
try {
logInfo('Model discovery started.', { engine, modelsUrl, force });
const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(5000) });
const rawText = await res.text();
if (!res.ok) {
logError('Model discovery returned non-OK status.', { engine, modelsUrl, status: res.status, body: summarizeText(rawText, 300) });
} else {
const data = rawText ? JSON.parse(rawText) as any : {};
models = engine === 'lmstudio'
? (data.data || []).map((m: any) => m.id)
: (data.models || []).map((m: any) => m.name);
if (models.length > 0) {
logInfo('Model discovery succeeded.', { engine, count: models.length, modelsPreview: models.slice(0, 5) });
}
}
} catch (e: any) {
logError('Model discovery failed.', { engine, modelsUrl, error: e?.message || String(e) });
}
online = models.length > 0;
this._modelsCache = { url, models: models.slice(), online, fetchedAt: Date.now() };
this._view.webview.postMessage({ type: 'engineStatus', value: { online, url } });
}
if (models.length === 0) {
models = defaultModel ? [defaultModel] : [];
}
const baseModel = defaultModel?.replace(/:\d+$/, '');
if (baseModel && baseModel !== defaultModel && models.includes(baseModel)) {
defaultModel = baseModel;
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
}
if (models.length > 0 && !defaultModel) {
// [State Persistence Fix v2] defaultModel이 완전히 비어있을 때만 첫 번째 모델로 설정
defaultModel = models[0];
await vscode.workspace.getConfiguration('g1nation').update('defaultModel', defaultModel, vscode.ConfigurationTarget.Global);
} else if (models.length > 0 && defaultModel && !models.includes(defaultModel)) {
// [State Persistence Fix v2] 저장된 모델이 로컬 엔진 목록에 없는 경우:
// 강제 리셋하지 않고, 저장된 모델을 목록 선두에 추가하여 사용자 선택을 보존
logInfo('Saved model not in local engine list. Preserving user selection.', { saved: defaultModel, localModels: models.slice(0, 3) });
models.unshift(defaultModel);
}
const defaultIdx = models.indexOf(defaultModel);
if (defaultIdx > 0) {
models.splice(defaultIdx, 1);
models.unshift(defaultModel);
}
let loadedModels: string[] = [];
if (resolveEngine(url) === 'lmstudio' && this._lmStudio) {
try {
loadedModels = await this._lmStudio.loadedModels();
} catch (e) {
logInfo('LM Studio loadedModels probe failed (non-fatal).', { error: (e as any)?.message ?? String(e) });
}
}
this._view.webview.postMessage({ type: 'modelsList', value: { models, selected: defaultModel, loadedModels } });
} catch (err) {
logError('Model list update failed.', err);
} finally {
this._modelDiscoveryInFlight = false;
}
void this._sendReadyStatus();
}
static _htmlTemplateCache: string | undefined;
_getHtml(webview: vscode.Webview): string {
if (!SidebarChatProvider._htmlTemplateCache) {
const tplPath = path.join(this._extensionUri.fsPath, 'media', 'sidebar.html');
SidebarChatProvider._htmlTemplateCache = fs.readFileSync(tplPath, 'utf8');
}
const mediaRoot = vscode.Uri.joinPath(this._extensionUri, 'media');
const stylesUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.css')).toString();
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'sidebar.js')).toString();
return SidebarChatProvider._htmlTemplateCache
.replace('__STYLES_URI__', stylesUri)
.replace('__SCRIPT_URI__', scriptUri);
}
}
/**
* Adapter that makes a {@link vscode.WebviewPanel} quack like a
* {@link vscode.WebviewView}, so providers written against the view API can
* mount inside an editor column without their internals knowing the difference.
*
* `onDidChangeVisibility` is synthesized from `onDidChangeViewState` — panels
* fire that event for both visibility *and* column moves, but the listener
* here only re-fires when the visible flag actually toggles.
*/
export function wrapPanelAsView(panel: vscode.WebviewPanel): vscode.WebviewView {
const visibilityEmitter = new vscode.EventEmitter<void>();
let _lastVisible = panel.visible;
panel.onDidChangeViewState(() => {
if (panel.visible !== _lastVisible) {
_lastVisible = panel.visible;
visibilityEmitter.fire();
}
});
panel.onDidDispose(() => visibilityEmitter.dispose());
const adapter: any = {
viewType: panel.viewType,
webview: panel.webview,
get visible() { return panel.visible; },
get title() { return panel.title; },
set title(v: string | undefined) { panel.title = v ?? ''; },
description: undefined as string | undefined,
badge: undefined as vscode.ViewBadge | undefined,
onDidChangeVisibility: visibilityEmitter.event,
onDidDispose: panel.onDidDispose,
show(preserveFocus?: boolean) {
panel.reveal(panel.viewColumn ?? vscode.ViewColumn.Three, preserveFocus);
},
};
return adapter as vscode.WebviewView;
}
/**
* Pixel Office "전체보기" webview panel용 HTML. 사이드바 mini 패널과 데이터
* 스키마는 같지만(`pixelOfficeUpdate` 메시지), 화면 구성은 사무실 그리드 +
* 직군별 캐릭터 배치로 완전히 다르다.
*
* 사무실 레이아웃:
* ┌────────────────────────────────────────────────┐
* │ Astra Office — 현재 작업 (스크롤 헤더) │
* │ CEO │
* │ [기획] [리서치] [디자인] │
* │ [개발] [QA] [감리] │
* │ Footer: progress bar + 닫기 │
* └────────────────────────────────────────────────┘
*
* 작업 중인 직군에 해당하는 캐릭터에 강조 테두리 + 머리 위 말풍선. 다른
* 캐릭터는 살짝 dim. 직군은 ROLE_CATEGORY_ORDER 순으로 배치 — Agent 코드
* 변경 없이 백엔드의 listActiveAgentsByCategory 결과를 그대로 활용 가능.
*/
function _pixelOfficePanelHtml(cspSource: string, assets: { derivedBase: string }): string {
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${cspSource} data:; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
<style>
:root{--bg:#0E1019;--wall:#202536;--floor:#302634;--floor2:#281F2C;--text:#F1F4FB;--muted:#A8B0C7;--accent:#7C83FF;}
*{box-sizing:border-box} body{margin:0;height:100vh;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;display:flex;flex-direction:column;overflow:hidden}
header{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.22);border-bottom:1px solid rgba(255,255,255,.08)}
.h-title{font-weight:800}.h-sub{font-size:11px;color:var(--muted)}.status{font-size:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.18);border-radius:999px}
.strip{display:flex;gap:16px;padding:8px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid rgba(255,255,255,.06)}.strip b{color:var(--text)}
.office{position:relative;flex:1;overflow:hidden;display:flex;align-items:center;justify-content:center;background:linear-gradient(180deg,#21283a 0 16%,transparent 16%),radial-gradient(ellipse at 50% 0%,rgba(124,131,255,.12),transparent 42%),linear-gradient(135deg,#322835,#271f2a)}
.office:before{content:'';position:absolute;left:0;right:0;top:16%;bottom:0;background-image:linear-gradient(rgba(255,255,255,.028) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.028) 1px,transparent 1px);background-size:48px 48px}
.office:after{content:'';position:absolute;left:0;right:0;top:15.5%;height:8px;background:linear-gradient(180deg,rgba(0,0,0,.36),transparent)}
.stage{position:relative;width:720px;height:585px;margin:0}
.wall-window{position:absolute;top:16px;width:86px;height:42px;border:3px solid rgba(206,223,255,.35);background:linear-gradient(180deg,rgba(160,208,255,.3),rgba(110,150,210,.1));box-shadow:inset 0 0 0 2px rgba(15,20,31,.55)}
.wall-window.w1{left:84px}.wall-window.w2{left:550px}
.obj,.desk,.char{position:absolute;image-rendering:pixelated}
.obj{filter:drop-shadow(3px 4px 0 rgba(0,0,0,.28));z-index:4}
.desk{width:112px;z-index:5;filter:drop-shadow(4px 5px 0 rgba(0,0,0,.32))}.desk.boss{width:136px}.label{position:absolute;left:50%;bottom:-10px;transform:translateX(-50%);font-size:10px;color:rgba(241,244,251,.78);white-space:nowrap;text-shadow:1px 1px #000}
.char{width:56px;height:72px;z-index:7;transition:left .9s cubic-bezier(.2,.7,.2,1),top .9s cubic-bezier(.2,.7,.2,1)}.char.walking{z-index:14}.char img{position:absolute;left:0;bottom:0;max-width:100%;max-height:100%;image-rendering:pixelated;filter:drop-shadow(2px 2px 0 rgba(0,0,0,.45));transform-origin:center bottom}
.char.active:before{content:'';position:absolute;left:24px;top:-10px;width:8px;height:8px;background:var(--role-color,var(--accent));box-shadow:0 0 12px var(--role-color,var(--accent));animation:po-pulse 1.6s ease-in-out infinite}
@keyframes po-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:.6}}
/* ── C. 직군별 페르소나 컬러 ── 책상 outline 가벼운 강조, 활성 캐릭터 위 점이 직군색.
data-role attribute로 자동 매핑. 사용자가 PNG sprite로 swap해도 컬러는 유지. */
.char[data-agent="ceo"],.desk[data-agent="ceo"] {--role-color:#A78BFA}
.char[data-agent="planner"],.desk[data-agent="planner"] {--role-color:#60A5FA}
.char[data-agent="researcher"],.desk[data-agent="researcher"] {--role-color:#10B981}
.char[data-agent="designer"],.desk[data-agent="designer"] {--role-color:#F472B6}
.char[data-agent="developer"],.desk[data-agent="developer"] {--role-color:#FBBF24}
.char[data-agent="qa"],.desk[data-agent="qa"] {--role-color:#22D3EE}
.char[data-agent="inspector"],.desk[data-agent="inspector"] {--role-color:#FB923C}
.char[data-agent="support"],.desk[data-agent="support"] {--role-color:#94A3B8}
.char.active::after{content:'';position:absolute;left:0;right:0;bottom:-4px;height:3px;background:var(--role-color,var(--accent));box-shadow:0 0 8px var(--role-color,var(--accent));border-radius:2px;animation:po-glow 1.6s ease-in-out infinite}
@keyframes po-glow{0%,100%{opacity:.7}50%{opacity:1}}
.desk{position:relative}
.desk::after{content:'';position:absolute;inset:-2px;border-radius:4px;border:2px solid transparent;pointer-events:none;transition:border-color .3s}
.stage:has(.char.active[data-agent="ceo"]) .desk[data-agent="ceo"]::after,
.stage:has(.char.active[data-agent="planner"]) .desk[data-agent="planner"]::after,
.stage:has(.char.active[data-agent="researcher"]) .desk[data-agent="researcher"]::after,
.stage:has(.char.active[data-agent="designer"]) .desk[data-agent="designer"]::after,
.stage:has(.char.active[data-agent="developer"]) .desk[data-agent="developer"]::after,
.stage:has(.char.active[data-agent="qa"]) .desk[data-agent="qa"]::after,
.stage:has(.char.active[data-agent="inspector"]) .desk[data-agent="inspector"]::after,
.stage:has(.char.active[data-agent="support"]) .desk[data-agent="support"]::after
{border-color:var(--role-color)}
.shadow{position:absolute;left:12px;bottom:0;width:28px;height:7px;background:radial-gradient(ellipse,rgba(0,0,0,.55),transparent 70%)}
.bubble{position:absolute;z-index:20;transform:translate(-50%,-100%);background:#fff;color:#222;padding:5px 8px;border-radius:8px;font-size:11px;box-shadow:2px 2px 0 rgba(0,0,0,.35);white-space:nowrap}
.edit-btn{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.2);color:#F1F4FB;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px}.edit-btn:hover{background:rgba(99,102,241,.25);border-color:#6366F1}
/* ── B. 워크플로우 미니 맵 ── 헤더 아래 dot strip. 각 dot이 stage 하나. 완료=
채워진 점, 활성=링 펄스, 대기=빈 점. 호버 시 라벨 표시. */
.mini-map{display:flex;gap:5px;align-items:center;padding:7px 16px;background:rgba(0,0,0,.3);border-bottom:1px solid rgba(255,255,255,.06);overflow-x:auto;scrollbar-width:none}.mini-map::-webkit-scrollbar{display:none}
.mini-map .mm-dot{position:relative;width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,.12);border:1.5px solid rgba(255,255,255,.18);flex-shrink:0;cursor:default;transition:all .25s}
.mini-map .mm-dot[data-status="done"]{background:#10B981;border-color:#10B981;box-shadow:0 0 4px rgba(16,185,129,.5)}
.mini-map .mm-dot[data-status="active"]{background:var(--accent);border-color:var(--accent);width:14px;height:14px;box-shadow:0 0 0 3px rgba(99,102,241,.3);animation:mm-pulse 1.4s ease-in-out infinite}
@keyframes mm-pulse{0%,100%{box-shadow:0 0 0 3px rgba(99,102,241,.3)}50%{box-shadow:0 0 0 6px rgba(99,102,241,.15)}}
.mini-map .mm-bar{flex:1;height:1px;background:linear-gradient(90deg,rgba(255,255,255,.08),rgba(255,255,255,.16))}
.mini-map .mm-label{position:absolute;left:50%;top:-22px;transform:translateX(-50%);font-size:10px;color:#F1F4FB;background:rgba(0,0,0,.85);padding:2px 6px;border-radius:3px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .15s;z-index:50}
.mini-map .mm-dot:hover .mm-label{opacity:1}
.mini-map .mm-counter{flex-shrink:0;font-size:10px;color:#94A3B8;margin-left:8px;white-space:nowrap}
/* ── E. Activity Ticker ── action-tag executor 결과를 하단 strip으로 흘림.
사용자가 에이전트의 *실제 행동*(파일 쓰기, 명령 실행)을 실시간으로 보며 신뢰. */
.ticker{position:relative;padding:5px 16px;background:rgba(99,102,241,.08);border-top:1px solid rgba(99,102,241,.18);overflow:hidden;font-size:11px;font-family:ui-monospace,monospace;height:24px}
.tk-track{display:flex;gap:18px;white-space:nowrap;animation:tk-roll 22s linear infinite;will-change:transform}
.ticker:hover .tk-track{animation-play-state:paused}
.tk-item{flex-shrink:0;color:#D7DBEA}
.tk-item.tk-ok{color:#10B981}
.tk-item.tk-warn{color:#F5C518}
.tk-item.tk-err{color:#EF4444}
.tk-item .tk-agent{color:#A78BFA;margin-right:5px;font-weight:600}
@keyframes tk-roll{from{transform:translateX(0)}to{transform:translateX(-50%)}}
/* ── D. 캐릭터 컨텍스트 메뉴 ── 편집 모드 X일 때 캐릭터 클릭하면 작은 메뉴 popup.
현재 turn 제어 + 최근 활동 보기. */
.ctx-menu{position:fixed;z-index:1000;background:#13162A;border:1px solid #2A2E3F;border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.6);padding:4px;min-width:170px;font-size:12px;color:#F1F4FB}
.ctx-menu-head{padding:6px 10px 4px;font-size:10px;color:#94A3B8;border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:4px}
.ctx-menu-head .cmh-role{color:var(--role-color,#A78BFA);font-weight:700;text-transform:uppercase}
.ctx-menu-item{display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:pointer;border-radius:4px;transition:background .12s}
.ctx-menu-item:hover{background:rgba(99,102,241,.18)}
.ctx-menu-item.danger:hover{background:rgba(239,68,68,.18);color:#FCA5A5}
.ctx-menu-divider{height:1px;background:rgba(255,255,255,.08);margin:3px 4px}
body[data-edit-mode="true"] .ctx-menu{display:none!important}
body:not([data-edit-mode="true"]) .char{cursor:pointer}
.ctx-detail{position:fixed;z-index:1001;background:#13162A;border:1px solid #2A2E3F;border-radius:8px;box-shadow:0 12px 36px rgba(0,0,0,.7);padding:16px 18px;color:#F1F4FB;min-width:320px;max-width:520px;max-height:60vh;overflow-y:auto;font-size:12px;line-height:1.5}
.ctx-detail h3{margin:0 0 8px;font-size:14px;color:var(--role-color,#A78BFA);text-transform:uppercase;letter-spacing:.04em}
.ctx-detail .cd-close{position:absolute;top:8px;right:10px;background:transparent;border:none;color:#94A3B8;font-size:16px;cursor:pointer}
.ctx-detail dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:4px 14px}
.ctx-detail dt{color:#94A3B8;font-weight:600;white-space:nowrap}
.ctx-detail dd{margin:0;color:#F1F4FB;overflow-wrap:anywhere}
.ctx-detail .cd-logs{margin-top:10px;padding:6px 8px;background:rgba(0,0,0,.3);border-radius:4px;font-family:ui-monospace,monospace;font-size:10.5px;max-height:120px;overflow-y:auto}
.edit-toolbar{display:flex;gap:8px;align-items:center;padding:6px 16px;background:rgba(99,102,241,.18);border-bottom:1px solid rgba(99,102,241,.4);font-size:11px;flex-wrap:wrap}.edit-toolbar .et-hint{flex:1;color:#D7DBEA;min-width:160px}.edit-toolbar button{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);color:#F1F4FB;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px}.edit-toolbar button:hover{background:rgba(99,102,241,.35)}
.edit-toolbar button.add{background:rgba(16,185,129,.22);border-color:rgba(16,185,129,.55)}.edit-toolbar button.add:hover{background:rgba(16,185,129,.4)}
.edit-toolbar button.del{background:rgba(239,68,68,.22);border-color:rgba(239,68,68,.55)}.edit-toolbar button.del:hover{background:rgba(239,68,68,.4)}.edit-toolbar button[disabled]{opacity:.4;cursor:not-allowed}
/* 선택된 item 의 속성 편집 패널 — 우측 슬라이드 */
.prop-panel{position:absolute;right:12px;top:90px;width:240px;background:#13162A;border:1px solid #2A2E3F;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.6);padding:12px;font-size:11px;color:#F1F4FB;z-index:25;display:none}
.prop-panel.show{display:block}
.prop-panel h4{margin:0 0 8px;font-size:12px;color:#A78BFA;text-transform:uppercase;letter-spacing:.04em}
.prop-panel .pp-row{margin-bottom:8px}
.prop-panel label{display:block;font-size:10px;color:#94A3B8;margin-bottom:2px}
.prop-panel select,.prop-panel input{width:100%;background:#0c1020;color:#F1F4FB;border:1px solid #2A2E3F;border-radius:4px;padding:3px 6px;font-size:11px}
.prop-panel .pp-thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:4px}
.prop-panel .pp-thumb{width:100%;aspect-ratio:1/1;background:#0c1020;border:1px solid #2A2E3F;border-radius:3px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:2px}
.prop-panel .pp-thumb img{max-width:100%;max-height:100%;image-rendering:pixelated}
.prop-panel .pp-thumb.active{border-color:#A78BFA;box-shadow:0 0 0 2px rgba(167,139,250,.35)}
/* 프랍 추가 picker — 모달 grid */
.prop-picker{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1100;display:flex;align-items:center;justify-content:center}
.prop-picker-box{background:#13162A;border:1px solid #2A2E3F;border-radius:10px;padding:14px;max-width:520px;max-height:80vh;overflow-y:auto;color:#F1F4FB}
.prop-picker-box h3{margin:0 0 10px;font-size:13px;color:#A78BFA}
.prop-picker-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
.prop-pick{background:#0c1020;border:1px solid #2A2E3F;border-radius:4px;padding:6px;cursor:pointer;text-align:center}
.prop-pick:hover{border-color:#A78BFA}
.prop-pick img{max-width:60px;max-height:60px;image-rendering:pixelated}
.prop-pick .pp-name{font-size:10px;color:#94A3B8;margin-top:4px;word-break:break-all}
/* 편집 모드 — 드래그 가능 요소 강조 */
body[data-edit-mode="true"] .stage{background-image:linear-gradient(rgba(99,102,241,.15) 1px,transparent 1px),linear-gradient(90deg,rgba(99,102,241,.15) 1px,transparent 1px);background-size:32px 32px}
body[data-edit-mode="true"] .desk,body[data-edit-mode="true"] .char,body[data-edit-mode="true"] .obj{cursor:grab;outline:1px dashed rgba(99,102,241,.5)}
body[data-edit-mode="true"] .desk:hover,body[data-edit-mode="true"] .char:hover,body[data-edit-mode="true"] .obj:hover{outline:2px solid #6366F1;z-index:30}
body[data-edit-mode="true"] .dragging{cursor:grabbing!important;opacity:.7;outline:2px solid #FB923C!important;z-index:40}
body[data-edit-mode="true"] .selected{outline:2px solid #F472B6!important;box-shadow:0 0 0 4px rgba(244,114,182,.25);z-index:35}
body[data-edit-mode="true"] .char .shadow{display:none}
footer{padding:8px 16px 12px;border-top:1px solid rgba(255,255,255,.08);background:rgba(0,0,0,.25);font-size:11px;color:var(--muted)}.progress{height:5px;background:rgba(255,255,255,.08);margin-bottom:6px}.bar{height:100%;width:0;background:var(--accent);transition:width .25s}
</style></head>
<body>
<header><div><div class="h-title">🏢 ASTRA OFFICE</div><div class="h-sub" id="agent">Astra</div></div><div style="display:flex;gap:8px;align-items:center;"><button id="editBtn" class="edit-btn" title="배치 편집 모드 토글">✏️ 편집</button><div class="status" id="status">idle</div></div></header>
<div id="miniMap" class="mini-map" style="display:none;"></div>
<div id="editToolbar" class="edit-toolbar" style="display:none;">
<span class="et-hint">드래그로 이동 · <b>R</b> 회전 · <b>]</b>/<b>[</b> 레이어 · 4px snap</span>
<button id="addDeskBtn" class="add" title="책상 추가">+ 책상</button>
<button id="addPropBtn" class="add" title="프랍(소품) 추가">+ 프랍</button>
<button id="deleteSelBtn" class="del" title="선택 항목 삭제" disabled>🗑 삭제</button>
<button id="layerUpBtn" title="레이어 위로 (])">⬆</button>
<button id="layerDownBtn" title="레이어 아래로 ([)">⬇</button>
<button id="saveBtn">💾 저장</button>
<button id="resetBtn" title="기본 배치로 복귀">↻ 디폴트</button>
<button id="cancelBtn" title="저장 안 하고 종료">✕ 취소</button>
</div>
<div class="strip"><span><b>작업</b> <span id="task">—</span></span><span><b>단계</b> <span id="step">—</span></span></div>
<main class="office"><div class="stage" id="stage"><div class="wall-window w1"></div><div class="wall-window w2"></div></div><div id="propPanel" class="prop-panel"></div></main>
<div id="ticker" class="ticker" style="display:none;"><div class="tk-track" id="tickerTrack"></div></div>
<footer><div class="progress"><div class="bar" id="bar"></div></div><div id="log">—</div></footer>
<script>(function(){
const base='${assets.derivedBase}'; const stage=document.getElementById('stage');
// \u2500\u2500 \uB370\uC774\uD130 \uBAA8\uB378 \u2500\u2500
// stations: \uCC45\uC0C1 + \uCE90\uB9AD\uD130 \uC815\uC758 \uBC30\uC5F4 (let \u2014 \uCD94\uAC00/\uC81C\uAC70 \uAC00\uB2A5).
// key = \uC548\uC815\uC801 \uC2DD\uBCC4\uC790 (DOM dataset.role \uB85C\uB3C4 \uC0AC\uC6A9). \uC0AC\uC6A9\uC790\uAC00 \uC0C8\uB85C \uB9CC\uB4E0 \uCC45\uC0C1\uC740 \uC790\uB3D9 \uC0DD\uC131.
// agentKey = \uC774 \uCC45\uC0C1\uC5D0 \uB9E4\uD551\uB41C \uC5D0\uC774\uC804\uD2B8 ID (ceo/planner/researcher/...). \uC5C6\uC73C\uBA74 idle.
// charRow = \uC0AC\uC6A9\uD560 sprite row (0~7). \uCE90\uB9AD\uD130 \uC678\uBAA8/\uBC29\uD5A5.
// deskSprite= \uCC45\uC0C1 PNG \uC774\uB984 (desk-main / desk-boss / desk-dark-mirror \uB4F1).
// objs: \uD504\uB78D \uC815\uC758 \uBC30\uC5F4 (let). user-add/remove.
const DEFAULT_STATIONS=[
{key:'ceo',agentKey:'ceo',label:'CEO',charRow:0,deskSprite:'desk-boss',deskX:304,deskY:84,deskW:136,seatX:331,seatY:115,face:'R',dock:[362,164],roam:[[320,196],[396,196]],boss:true},
{key:'planner',agentKey:'planner',label:'\uAE30\uD68D',charRow:1,deskSprite:'desk-main',deskX:60,deskY:228,deskW:112,seatX:64,seatY:264,face:'R',dock:[96,322],roam:[[154,350],[200,330]]},
{key:'researcher',agentKey:'researcher',label:'\uB9AC\uC11C\uCE58',charRow:2,deskSprite:'desk-dark-mirror',deskX:236,deskY:214,deskW:112,seatX:284,seatY:248,face:'L',dock:[304,310],roam:[[322,340],[344,322]]},
{key:'designer',agentKey:'designer',label:'\uB514\uC790\uC778',charRow:3,deskSprite:'desk-main',deskX:402,deskY:240,deskW:112,seatX:406,seatY:276,face:'R',dock:[438,334],roam:[[492,350],[520,326]]},
{key:'developer',agentKey:'developer',label:'\uAC1C\uBC1C',charRow:4,deskSprite:'desk-dark-mirror',deskX:56,deskY:410,deskW:112,seatX:104,seatY:442,face:'L',dock:[124,500],roam:[[150,534],[188,518]]},
{key:'qa',agentKey:'qa',label:'QA',charRow:5,deskSprite:'desk-main',deskX:232,deskY:394,deskW:112,seatX:236,seatY:430,face:'R',dock:[268,486],roam:[[320,520],[352,500]]},
{key:'inspector',agentKey:'inspector',label:'\uAC10\uB9AC',charRow:6,deskSprite:'desk-dark-mirror',deskX:408,deskY:420,deskW:112,seatX:456,seatY:452,face:'L',dock:[476,506],roam:[[506,532],[540,502]]},
{key:'support',agentKey:'support',label:'\uC9C0\uC6D0',charRow:7,deskSprite:'desk-main',deskX:220,deskY:472,deskW:112,seatX:224,seatY:504,face:'R',dock:[256,548],roam:[[360,548],[484,540]]},
];
// \uC5D0\uC774\uC804\uD2B8 ID alias \u2014 \uAC19\uC740 \uD398\uB974\uC18C\uB098\uC758 \uB2E4\uC591\uD55C \uD638\uCE6D\uC744 \uAC19\uC740 book agentKey \uB85C. (writer\u2192planner, editor\u2192designer, secretary\u2192support, business\u2192inspector)
const AGENT_ALIASES={writer:'planner',editor:'designer',secretary:'support',business:'inspector'};
// \uB9E4\uD551 dropdown \uC5D0 \uBCF4\uC5EC\uC904 \uC5D0\uC774\uC804\uD2B8 \uD6C4\uBCF4. agentKey \uAC00 unique \uD55C base set.
const AGENT_CHOICES=['','ceo','planner','researcher','designer','developer','qa','inspector','support'];
// \uCD94\uAC00 \uAC00\uB2A5\uD55C \uCC45\uC0C1 sprite \uD6C4\uBCF4 (assets/pixelOffice/derived \uC5D0 \uC788\uB294 desk-* PNG).
const DESK_SPRITE_CHOICES=['desk-main','desk-boss','desk-dark-mirror','desk-main-mirror','desk-dark','desk-boss-mirror','desk-main-front','desk-dark-front','desk-boss-front','desk-partition'];
// \uCD94\uAC00 \uAC00\uB2A5\uD55C \uD504\uB78D sprite \uD6C4\uBCF4.
const PROP_SPRITE_CHOICES=['board','plant-tall','bookshelf','plant-bushy','partition','cooler','filing','couch','rug','shelf','printer','monitor-blue','monitor-black','chair-blue','crt'];
const DEFAULT_PROPS=[
{name:'board',x:316,y:12,w:88},{name:'plant-tall',x:44,y:92,w:42},{name:'bookshelf',x:86,y:70,w:54},
{name:'plant-bushy',x:642,y:96,w:42},{name:'partition',x:520,y:208,w:72},{name:'cooler',x:640,y:248,w:38},
{name:'filing',x:620,y:330,w:42},{name:'couch',x:578,y:432,w:96},{name:'rug',x:560,y:510,w:126},
{name:'shelf',x:40,y:504,w:118},{name:'printer',x:520,y:520,w:58},{name:'monitor-blue',x:356,y:56,w:44},
];
let stations=[]; // mutable, \uC2DC\uC791 \uC2DC default \uB610\uB294 saved layout \uB85C \uCC44\uC6C0.
let __nextDeskN=100; // user-add \uCC45\uC0C1 id \uCE74\uC6B4\uD130 (default \uC640 \uCDA9\uB3CC \uD68C\uD53C \uC704\uD574 \uD070 \uC218\uC5D0\uC11C \uC2DC\uC791).
let __nextObjN=0;
const stationByKey={}; // \uBE60\uB978 lookup. stations \uBCC0\uACBD \uC2DC rebuild.
const __deskWrap={}; // role \u2192 desk DOM wrap.
const chars={}; // role \u2192 char DOM.
const anim={}; // role \u2192 animation state.
function png(name){return base+'/'+name+'.png'}
function _rebuildStationIndex(){
Object.keys(stationByKey).forEach(k=>delete stationByKey[k]);
stations.forEach(st=>{ stationByKey[st.key]=st; });
}
// agentKey \u2192 station.key \uB85C \uB77C\uC6B0\uD305. roleMap \uC758 \uB3D9\uC801 \uBC84\uC804.
function findStationByAgent(agentId){
if(!agentId) return null;
const a = AGENT_ALIASES[agentId] || agentId;
for(const st of stations){ if(st.agentKey === a) return st; }
return null;
}
// \uC61B \uCF54\uB4DC \uD638\uD658 \u2014 roleMap[x] \uD638\uCD9C \uD328\uD134\uC744 \uD568\uC218\uB85C.
const roleMap=new Proxy({},{get:(_,k)=>{ const s=findStationByAgent(k); return s?s.key:null; }});
function addImg(name,x,y,w){
const i=document.createElement('img');
i.src=png(name); i.className='obj';
i.dataset.objId='obj_'+(__nextObjN++); i.dataset.objName=name;
if(w!=null) i.dataset.objW=w;
i.style.left=x+'px'; i.style.top=y+'px';
if(w!=null) i.style.width=w+'px';
stage.appendChild(i); return i;
}
function addDesk(st){
const wrap=document.createElement('div');
wrap.className='desk '+(st.boss?'boss':'');
wrap.dataset.role=st.key;
if(st.agentKey) wrap.dataset.agent=st.agentKey;
wrap.style.left=st.deskX+'px'; wrap.style.top=st.deskY+'px'; wrap.style.width=st.deskW+'px';
const img=document.createElement('img'); img.src=png(st.deskSprite); img.style.width='100%';
wrap.appendChild(img);
const l=document.createElement('div'); l.className='label'; l.textContent=st.label;
wrap.appendChild(l);
stage.appendChild(wrap);
__deskWrap[st.key]=wrap;
return wrap;
}
function addChar(st){
const ch=document.createElement('div');
ch.className='char'; ch.dataset.role=st.key;
if(st.agentKey) ch.dataset.agent=st.agentKey;
ch.style.left=st.seatX+'px'; ch.style.top=st.seatY+'px';
ch.dataset.homeX=st.seatX; ch.dataset.homeY=st.seatY; ch.dataset.row=st.charRow;
ch.innerHTML='<img><div class="shadow"></div>';
stage.appendChild(ch);
chars[st.key]=ch;
anim[st.key]={row:st.charRow,frame:0,dir:0,mode:'sit',face:st.face,route:0};
const img=ch.querySelector('img');
if(st.face === 'U' || st.face === 'D'){
img.src=png('walk-r'+st.charRow+'-d'+_faceToWalkDir(st.face)+'-f0');
img.style.transform='none';
} else {
img.src=png('idle-r'+st.charRow+'-f0');
img.style.transform=st.face==='R'?'scaleX(-1)':'none';
}
return ch;
}
function buildStation(st){ addDesk(st); if(!st.noChar) addChar(st); }
function _renderDefaultStations(){
// default 8 stations + default props.
stations = DEFAULT_STATIONS.map(s=>Object.assign({},s));
_rebuildStationIndex();
stations.forEach(buildStation);
DEFAULT_PROPS.forEach(p=>addImg(p.name,p.x,p.y,p.w));
}
_renderDefaultStations();
// ── 4방향 dir 매핑 ──
// PNG 파일명 규약: walk-r<row>-d<DIR>-f<frame>.png
// 사용자의 sprite 컨벤션이 다르면 이 4 상수만 수정하면 됨.
// 기본값은 일반적인 RPG 4방향 시트 순서 (caliverse · RPGMaker 등).
const DIR_DOWN = 0; // 정면 (카메라/사용자 쪽)
const DIR_LEFT = 1;
const DIR_RIGHT = 2;
const DIR_UP = 3; // 뒤
// idle/work 의 정면(D) / 후면(U) sprite 는 별도로 없으므로 walk 의 같은 방향
// frame 0 (정지 포즈) 를 그대로 빌려쓴다.
function _faceToWalkDir(face){
if(face === 'D') return DIR_DOWN;
if(face === 'U') return DIR_UP;
if(face === 'L') return DIR_LEFT;
return DIR_RIGHT;
}
function setSprite(role,mode,frame=0,dir=0){
const ch=chars[role]; if(!ch) return;
const a=anim[role]; if(!a) return;
a.mode=mode;a.frame=frame;a.dir=dir;
ch.classList.toggle('walking',mode==='walk');
const img=ch.querySelector('img');
if(mode==='walk'){
// 4방향 walk sprite — 좌우 sprite를 따로 제공하므로 scaleX 반전은 *안* 한다.
img.src=png('walk-r'+a.row+'-d'+dir+'-f'+frame);
img.style.transform='none';
} else if(a.face === 'U' || a.face === 'D'){
// 위/아래 face 는 idle/work sprite 가 없으므로 walk 의 같은 방향 정지 포즈로.
img.src=png('walk-r'+a.row+'-d'+_faceToWalkDir(a.face)+'-f0');
img.style.transform='none';
} else if(mode==='work'){
img.src=png('work-r'+a.row+'-f'+frame);
img.style.transform=(a.face==='R'?'scaleX(-1)':'none');
} else {
img.src=png('idle-r'+a.row+'-f'+frame);
img.style.transform=(a.face==='R'?'scaleX(-1)':'none');
}
}
function move(role,x,y){
const ch=chars[role]; if(!ch) return; // 캐릭터 삭제된 자리면 무시.
const a=anim[role]; if(!a) return;
const cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top),
dx=x-cx,dy=y-cy;
// 주축 결정: 큰 쪽을 우선해 4방향 중 하나로 매핑. 동률이면 가로 우선.
let dir;
if(Math.abs(dx) >= Math.abs(dy)){
dir = dx >= 0 ? DIR_RIGHT : DIR_LEFT;
} else {
dir = dy >= 0 ? DIR_DOWN : DIR_UP;
}
// 도착 후 idle/sit 시 좌우 face도 마지막 가로 이동에 맞춰 갱신. UP/DOWN은
// 기존 face 유지 — 4방향 idle sprite가 없을 때 좌우만이라도 자연스럽게.
if(dir === DIR_LEFT) a.face = 'L';
if(dir === DIR_RIGHT) a.face = 'R';
setSprite(role,'walk',0,dir);
ch.style.left=x+'px';
ch.style.top=y+'px';
}
setInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='walk'){a.frame=(a.frame+1)%5;setSprite(k,'walk',a.frame,a.dir)}else if(a.mode==='work'){a.frame=(a.frame+1)%4;setSprite(k,'work',a.frame)} });},220)
setInterval(()=>{Object.keys(chars).forEach(k=>{const a=anim[k];if(a.mode==='sit'){a.frame=(a.frame+1)%2;setSprite(k,'sit',a.frame)} });},700)
// ── 책상 회피 path planner ──
// walkPath의 각 leg를 직선이 아닌 *책상을 우회하는* L자 또는 corridor 경로로
// 펴서 캐릭터가 책상을 가로지르지 않게. 책상이 회전됐을 때를 대비해 padding
// 충분히. 사용자 layout이 너무 빡빡해 모든 시도가 fail이면 직선 fallback.
function _deskRects(){
return stations.map(st=>{
const w=__deskWrap[st.key]; if(!w) return null;
const x=parseFloat(w.style.left), y=parseFloat(w.style.top);
const ww=parseFloat(w.style.width)||100;
const hh=w.offsetHeight||40;
const pad=20;
return {x:x-pad, y:y-pad, w:ww+pad*2, h:hh+pad*2};
}).filter(Boolean);
}
function _segIntersect(x1,y1,x2,y2,x3,y3,x4,y4){
const denom=(x1-x2)*(y3-y4)-(y1-y2)*(x3-x4);
if(Math.abs(denom)<0.0001) return false;
const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/denom;
const u=-((x1-x2)*(y1-y3)-(y1-y2)*(x1-x3))/denom;
return t>=0&&t<=1&&u>=0&&u<=1;
}
function _segHitsRect(x1,y1,x2,y2,r){
// 양 끝점이 rect 안이면 즉시 충돌
const inR=(x,y)=>x>=r.x&&x<=r.x+r.w&&y>=r.y&&y<=r.y+r.h;
if(inR(x1,y1)||inR(x2,y2)) return true;
// 4변과 교차 검사
return _segIntersect(x1,y1,x2,y2,r.x,r.y,r.x+r.w,r.y)
|| _segIntersect(x1,y1,x2,y2,r.x+r.w,r.y,r.x+r.w,r.y+r.h)
|| _segIntersect(x1,y1,x2,y2,r.x+r.w,r.y+r.h,r.x,r.y+r.h)
|| _segIntersect(x1,y1,x2,y2,r.x,r.y+r.h,r.x,r.y);
}
function _pathClear(pts,rects){
for(let i=0;i<pts.length-1;i++){
const [a,b]=[pts[i],pts[i+1]];
for(const r of rects){ if(_segHitsRect(a[0],a[1],b[0],b[1],r)) return false; }
}
return true;
}
function _planPath(fromX,fromY,toX,toY){
const rects=_deskRects();
if(rects.length===0) return [[toX,toY]];
// 1) 직선
if(_pathClear([[fromX,fromY],[toX,toY]],rects)) return [[toX,toY]];
// 2) 가로 먼저 L
if(_pathClear([[fromX,fromY],[toX,fromY],[toX,toY]],rects)) return [[toX,fromY],[toX,toY]];
// 3) 세로 먼저 L
if(_pathClear([[fromX,fromY],[fromX,toY],[toX,toY]],rects)) return [[fromX,toY],[toX,toY]];
// 4) 사무실 corridor를 통과 — y 통로 후보 (윗·중간·아랫줄 책상 사이)
// stage 높이에 따라 자동 후보 생성: 0, h/4, h/2, 3h/4, h
const sh=stage.offsetHeight||600;
const ycands=[20,Math.round(sh*0.32),Math.round(sh*0.6),Math.round(sh*0.82),sh-20];
for(const cy of ycands){
const trial=[[fromX,fromY],[fromX,cy],[toX,cy],[toX,toY]];
if(_pathClear(trial,rects)) return [[fromX,cy],[toX,cy],[toX,toY]];
}
// 5) x 통로 후보
const sw=stage.offsetWidth||700;
const xcands=[20,Math.round(sw*0.32),Math.round(sw*0.6),Math.round(sw*0.82),sw-20];
for(const cx of xcands){
const trial=[[fromX,fromY],[cx,fromY],[cx,toY],[toX,toY]];
if(_pathClear(trial,rects)) return [[cx,fromY],[cx,toY],[toX,toY]];
}
// 최후의 fallback — 직선 (책상을 가로지를 수 있지만 적어도 멈추진 않음)
return [[toX,toY]];
}
function walkPath(role,points,done,route){
const a=anim[role],token=route??++a.route;
if(token!==a.route) return;
if(!points.length){ if(done) done(); return; }
const ch=chars[role];
const cx=parseFloat(ch.style.left), cy=parseFloat(ch.style.top);
const [pt,...rest]=points;
// 현재 위치 → 다음 waypoint를 책상 회피 경로로 펴기.
const planned=_planPath(cx,cy,pt[0],pt[1]);
// 첫 step 이동하고, 나머지 planned 점들 + 원본 rest를 큐로.
const next=planned[0];
move(role,next[0],next[1]);
const tail=planned.slice(1).concat(rest);
setTimeout(()=>walkPath(role,tail,done,token),950);
}
function sendHome(role,mode='sit'){
const st=stationByKey[role],ch=chars[role];
if(!st || !ch) return; // 책상/캐릭터 부재 시 no-op.
const hx=st.seatX,hy=st.seatY,cx=parseFloat(ch.style.left),cy=parseFloat(ch.style.top);
anim[role].route++;
if(Math.abs(cx-hx)<1&&Math.abs(cy-hy)<1){setSprite(role,mode);return;}
walkPath(role,[st.dock,[hx,hy]],()=>setSprite(role,mode));
}
setInterval(()=>{
const free=Object.keys(chars).filter(k=>anim[k]?.mode==='sit'&&!chars[k].classList.contains('active'));
if(!free.length)return;
const k=free[Math.floor(Math.random()*free.length)],st=stationByKey[k];
if(!st || !Array.isArray(st.roam) || !st.roam.length || !Array.isArray(st.dock)) return;
const pt=st.roam[Math.floor(Math.random()*st.roam.length)];
walkPath(k,[st.dock,pt,st.dock,[st.seatX,st.seatY]],()=>setSprite(k,'sit'));
},5600);
function activate(role){Object.keys(chars).forEach(k=>chars[k].classList.toggle('active',k===role))}
function bubble(role,text){const ch=chars[role];if(!ch||!text)return;const b=document.createElement('div');b.className='bubble';b.textContent=text;b.style.left=(parseFloat(ch.style.left)+28)+'px';b.style.top=(parseFloat(ch.style.top)-6)+'px';stage.appendChild(b);setTimeout(()=>b.remove(),1600)}
// ── A. 상태 계층화 ──
// 모든 phase event에 시퀀스를 큐에 넣으면 캐릭터가 끊임없이 걸어다녀 산만함.
// 일반 상태(executing/reviewing/planning/analyzing 등)는 *정적 갱신*만 하고,
// critical pivot(error/need_clarification/review_failed/approval/done 등)에서만
// 캐릭터 시뮬레이션 + 말풍선 강조. 사용자에게 정말 *알릴 만한 변화*에 시각
// 집중력 몰아주기.
const CRITICAL_STATUSES = new Set(['need_clarification','waiting_approval','error','done']);
const CRITICAL_BUBBLE_TYPES = new Set(['warning','error','success']);
const CRITICAL_TEXT_RE = /버그|오류|실패|승인|중단|완료|❌|🛑|✅|⚠️|보완|재작업|결재/;
function _isCriticalBubble(b){
if(!b) return false;
if(b.type && CRITICAL_BUBBLE_TYPES.has(b.type)) return true;
if(b.text && CRITICAL_TEXT_RE.test(b.text)) return true;
return false;
}
function routeBubble(b){
// 일반 status bubble은 무시 — 큐 적체 방지. 사용자에게 의미 있는 신호만 통과.
if(!_isCriticalBubble(b)) return;
const role = roleMap[b?.agentId] || 'ceo';
bubble(role, b?.text || '');
}
let _prevStatus = null;
function apply(s){
_lastState = s; // D. 컨텍스트 메뉴 / 세부보기에서 사용.
const st = s?.status || 'idle';
// 정적 갱신은 *항상* — 헤더/태스크/단계/로그/프로그레스.
document.getElementById('status').textContent = st;
document.getElementById('status').className = 'status s-' + st;
document.getElementById('agent').textContent = s?.agentName || 'Astra';
document.getElementById('task').textContent = s?.currentTask || '—';
document.getElementById('step').textContent = s?.currentStep || '—';
document.getElementById('log').textContent = (s?.recentLogs||[]).slice(-1)[0] || '—';
document.getElementById('bar').style.width = Math.round((s?.progress||0)*100) + '%';
// B. 미니 맵 렌더 — pipelineStages가 있을 때만 보임.
const mm = document.getElementById('miniMap');
const stages = s?.pipelineStages;
if(Array.isArray(stages) && stages.length > 0){
mm.style.display = 'flex';
mm.innerHTML = '';
const doneN = stages.filter(x=>x.status==='done').length;
stages.forEach((stg, i)=>{
const dot = document.createElement('div');
dot.className = 'mm-dot';
dot.dataset.status = stg.status || 'pending';
const label = document.createElement('span');
label.className = 'mm-label';
label.textContent = (i+1) + '. ' + (stg.label || '단계') + (stg.agent ? ' · ' + stg.agent : '');
dot.appendChild(label);
mm.appendChild(dot);
if(i < stages.length - 1){
const bar = document.createElement('div');
bar.className = 'mm-bar';
mm.appendChild(bar);
}
});
const counter = document.createElement('div');
counter.className = 'mm-counter';
counter.textContent = doneN + '/' + stages.length;
mm.appendChild(counter);
} else {
mm.style.display = 'none';
}
// 활성 캐릭터 결정. roleMap 은 agentKey → 실제 존재하는 station.key 로 lookup
// 하므로, 매핑된 책상이 없으면 null. 사용자가 default ceo 책상을 지워도 안전.
let role = null;
const m = (s?.message || '').match(/^([a-z0-9_-]+)/i);
if(m) role = roleMap[m[1]] || null;
if(['planning','analyzing','waiting_approval'].includes(st)) role = roleMap['ceo'] || role;
if(st === 'reviewing') role = roleMap['inspector'] || role;
// 활성 outline은 *항상* 즉시 반영 (시뮬레이션 없이도 누가 일하는지 보임).
activate(role);
// 시뮬레이션은 *status 전환 + critical 진입* 에서만.
const isPivot = CRITICAL_STATUSES.has(st);
const isTransition = st !== _prevStatus;
if(isTransition){
// 상태가 바뀔 때 작업 마무리한 캐릭터들 자리 정리(work→sit).
Object.keys(chars).forEach(k => {
if(k !== role && anim[k].mode === 'work') sendHome(k, 'sit');
});
// pivot 상태 진입은 walk + 말풍선 시뮬레이션 트리거.
if(isPivot && role){
sendHome(role, st === 'executing' || st === 'reviewing' ? 'work' : 'sit');
} else if(role){
// 일반 상태는 *정적 모드만* — 캐릭터를 그 자리에서 mode만 바꿈, 걷지 않음.
const ch = chars[role];
if(ch){
const cx = parseFloat(ch.style.left), cy = parseFloat(ch.style.top);
const st_ = stationByKey[role];
const home = st_ ? Math.abs(cx - st_.seatX) < 1 && Math.abs(cy - st_.seatY) < 1 : false;
if(home){
// 이미 자리에 있으면 mode만 work/sit으로 토글 (걷기 없음).
setSprite(role, ['executing','reviewing'].includes(st) ? 'work' : 'sit');
} else {
// 자리를 벗어나 있으면 한 번만 복귀 — 그 이후 같은 status가 연속 와도 다시 보내지 않음.
sendHome(role, ['executing','reviewing'].includes(st) ? 'work' : 'sit');
}
}
}
if(st === 'done'){
// 완료 — 모두 자리 복귀 후 단체 sit.
Object.keys(chars).forEach(k => sendHome(k, 'work'));
const ceoRole = roleMap['ceo']; if(ceoRole) bubble(ceoRole, '완료!');
setTimeout(() => Object.keys(chars).forEach(k => sendHome(k, 'sit')), 1800);
}
}
_prevStatus = st;
}
// D. 캐릭터 컨텍스트 메뉴 — 편집 모드 X일 때만. abort/세부보기/말풍선 정리.
let _lastState = null;
function _closeCtxMenu(){
document.querySelectorAll('.ctx-menu, .ctx-detail').forEach(el => el.remove());
}
document.addEventListener('click', e=>{
// 메뉴 외부 클릭 → 닫기
if(!e.target.closest('.ctx-menu') && !e.target.closest('.ctx-detail') && !e.target.closest('.char')){
_closeCtxMenu();
}
});
stage.addEventListener('click', e=>{
if(_editMode) return; // 편집 모드에선 드래그용 mousedown이 우선
const ch = e.target.closest('.char');
if(!ch) return;
const role = ch.dataset.role;
if(!role) return;
e.stopPropagation();
_closeCtxMenu();
const menu = document.createElement('div');
menu.className = 'ctx-menu';
menu.style.setProperty('--role-color', getComputedStyle(ch).getPropertyValue('--role-color') || '#A78BFA');
const head = document.createElement('div');
head.className = 'ctx-menu-head';
const st = stationByKey[role] || {label:role};
head.innerHTML = '<span class="cmh-role">'+ (st.label||role) +'</span><br><span style="color:#D7DBEA;font-size:10px;">' + role + '</span>';
menu.appendChild(head);
// 옵션
const addItem = (label, danger, onClick)=>{
const it = document.createElement('div');
it.className = 'ctx-menu-item' + (danger?' danger':'');
it.textContent = label;
it.onclick = (ev)=>{ ev.stopPropagation(); onClick(); _closeCtxMenu(); };
menu.appendChild(it);
};
addItem('📋 현재 작업 보기', false, ()=>{
_showDetail(role, _lastState);
});
addItem('💬 말풍선 보내기', false, ()=>{
const msg = window.prompt(role + '에게 보낼 메시지', '');
if(msg) bubble(role, msg);
});
const sep = document.createElement('div'); sep.className='ctx-menu-divider'; menu.appendChild(sep);
addItem('🛑 현재 작업 중단', true, ()=>{
try{ vscode.postMessage({type: 'pixelOfficeCommand', cmd: 'abort', role}); }catch{}
});
// 위치 — 클릭한 캐릭터 위
document.body.appendChild(menu);
const r = ch.getBoundingClientRect();
const mw = menu.offsetWidth, mh = menu.offsetHeight;
let mx = r.left + r.width/2 - mw/2, my = r.top - mh - 6;
if(my < 8) my = r.bottom + 6;
mx = Math.max(8, Math.min(window.innerWidth - mw - 8, mx));
menu.style.left = mx + 'px';
menu.style.top = my + 'px';
});
function _showDetail(role, state){
_closeCtxMenu();
const overlay = document.createElement('div');
overlay.className = 'ctx-detail';
const ch = chars[role];
if(ch) overlay.style.setProperty('--role-color', getComputedStyle(ch).getPropertyValue('--role-color') || '#A78BFA');
const st = stationByKey[role] || {label:role};
const close = document.createElement('button'); close.className='cd-close'; close.textContent='✕'; close.onclick=_closeCtxMenu;
overlay.appendChild(close);
const h = document.createElement('h3'); h.textContent = (st.label||role) + ' · ' + role;
overlay.appendChild(h);
const dl = document.createElement('dl');
const add = (k,v)=>{ if(v==null||v==='') return; const dt=document.createElement('dt');dt.textContent=k;const dd=document.createElement('dd');dd.textContent=v;dl.appendChild(dt);dl.appendChild(dd); };
add('상태', state?.status);
add('단계', state?.currentStep);
add('다음', state?.nextStep);
add('메모', state?.message);
if(state?.requirementContract){
const c = state.requirementContract;
add('목표', c.goal);
add('맥락', c.context);
add('형식', c.format);
if(Array.isArray(c.criteria) && c.criteria.length) add('기준', c.criteria.join(' · '));
if(Array.isArray(c.openQuestions) && c.openQuestions.length) add('미해결 질문', c.openQuestions.join(' / '));
}
overlay.appendChild(dl);
if(Array.isArray(state?.recentLogs) && state.recentLogs.length){
const logs = document.createElement('div'); logs.className='cd-logs';
logs.innerHTML = state.recentLogs.map(l=>'<div>'+ (l||'').replace(/[<>]/g,'') +'</div>').join('');
overlay.appendChild(logs);
}
document.body.appendChild(overlay);
const ow=overlay.offsetWidth, oh=overlay.offsetHeight;
overlay.style.left = Math.max(20, (window.innerWidth-ow)/2) + 'px';
overlay.style.top = Math.max(40, (window.innerHeight-oh)/2) + 'px';
}
// E. Activity ticker state — 최근 N개 행동만 ring buffer로. 너무 길어지면
// 자동으로 가장 오래된 것 drop.
const _tickerItems = [];
const TICKER_MAX = 12;
function _classifyTickerItem(text){
if(/❌|fail|error|⚠️/i.test(text)) return 'tk-err';
if(/✅|ok|created|edited|completed/i.test(text)) return 'tk-ok';
if(/⚠|warn|hollow/i.test(text)) return 'tk-warn';
return '';
}
function _renderTicker(){
const wrap = document.getElementById('ticker');
const track = document.getElementById('tickerTrack');
if(_tickerItems.length === 0){ wrap.style.display = 'none'; return; }
wrap.style.display = 'block';
// 무한 스크롤처럼 보이게 동일 리스트 2벌 연결.
const html = _tickerItems.map(it => {
const cls = _classifyTickerItem(it.text);
const ag = it.agentId ? '<span class="tk-agent">'+ it.agentId +'</span>' : '';
return '<span class="tk-item '+ cls +'">'+ ag + (it.text || '').replace(/[<>]/g,'') +'</span>';
}).join('');
track.innerHTML = html + html;
}
window.addEventListener('message',e=>{
const d = e.data;
if(!d) return;
if(d.type === 'pixelOfficeUpdate'){
const v = d.value;
if(!v) return;
if(v.state) apply(v.state);
if(Array.isArray(v.bubbles)) v.bubbles.forEach(routeBubble);
} else if(d.type === 'pixelOfficeActivity'){
const v = d.value;
if(!v || !Array.isArray(v.items)) return;
for(const it of v.items){
_tickerItems.push(it);
if(_tickerItems.length > TICKER_MAX) _tickerItems.shift();
}
_renderTicker();
}
});
try{vscode.postMessage({type:'getPixelOfficeState'})}catch{}
// ─────────────── Layout Editor ───────────────
// 사용자가 ✏️ 편집 버튼을 누르면 편집 모드 진입. 책상/캐릭터/소품을 드래그로
// 위치 조정 가능. 4px snap. 저장 시 백엔드 workspace state에 영구 저장. 다음
// 패널 오픈 때 자동 복원. 디폴트로 복귀 버튼도 제공.
let _editMode=false, _drag=null, _dragDX=0, _dragDY=0, _snapshotBeforeEdit=null, _selected=null;
function _snapshotLayout(){
// 새 schema v2: cells 가 단순 좌표 패치가 아니라 stations 전체 정의를 담는다.
// (책상 추가/제거가 가능하니 default 와의 delta 로는 표현 불가.) v1 호환을 위해
// _restoreLayout 이 양쪽 모두 처리.
return {
schema: 2,
cells: stations.map(st=>{
const ch = chars[st.key];
return {
roleKey: st.key,
agentKey: st.agentKey || '',
label: st.label || '',
charRow: st.charRow ?? 0,
deskSprite: st.deskSprite || 'desk-main',
face: st.face || 'R',
boss: !!st.boss,
noChar: !!st.noChar,
dock: st.dock,
roam: st.roam,
deskX: parseFloat(__deskWrap[st.key].style.left),
deskY: parseFloat(__deskWrap[st.key].style.top),
deskW: parseFloat(__deskWrap[st.key].style.width),
deskRot: parseFloat(__deskWrap[st.key].dataset.rot || '0'),
deskZ: parseFloat(__deskWrap[st.key].dataset.z || '0'),
seatX: ch ? parseFloat(ch.style.left) : (st.seatX ?? 0),
seatY: ch ? parseFloat(ch.style.top) : (st.seatY ?? 0),
charRot: ch ? parseFloat(ch.dataset.rot || '0') : 0,
charZ: ch ? parseFloat(ch.dataset.z || '0') : 0,
};
}),
objs: Array.from(stage.querySelectorAll('img.obj')).map(el=>({
id: el.dataset.objId,
name: el.dataset.objName,
x: parseFloat(el.style.left),
y: parseFloat(el.style.top),
w: el.dataset.objW ? parseFloat(el.dataset.objW) : undefined,
rot: parseFloat(el.dataset.rot || '0'),
z: parseFloat(el.dataset.z || '0'),
})),
};
}
// stage 에 그려진 모든 desk/char/obj DOM 제거 (벽 창문 제외). 레이아웃 리빌드 직전에 호출.
function _clearStage(){
Array.from(stage.querySelectorAll('.desk,.char,img.obj')).forEach(el=>el.remove());
Object.keys(__deskWrap).forEach(k=>delete __deskWrap[k]);
Object.keys(chars).forEach(k=>delete chars[k]);
Object.keys(anim).forEach(k=>delete anim[k]);
stations = [];
}
function _applyRot(el, rot){
if(!el) return;
const r = typeof rot==='number' ? rot : 0;
el.dataset.rot = String(r);
el.style.transform = r === 0 ? '' : ('rotate('+r+'deg)');
}
function _applyZ(el, z){
if(!el) return;
if(typeof z !== 'number') return;
el.dataset.z = String(z);
el.style.zIndex = z === 0 ? '' : String(z);
}
// v2 schema 또는 cell 에 desk 정의 필드가 있으면 stage 를 통째로 재구축.
// v1 (옛 포맷) 이면 좌표만 패치하는 in-place 갱신.
function _isV2Snap(snap){
if(!snap || !Array.isArray(snap.cells)) return false;
if(snap.schema === 2) return true;
return snap.cells.some(c => c && (typeof c.deskSprite === 'string' || typeof c.agentKey === 'string' || typeof c.charRow === 'number'));
}
function _restoreLayout(snap){
if(!snap) return;
if(_isV2Snap(snap)){
_clearStage();
(snap.cells||[]).forEach(c=>{
const st = {
key: c.roleKey,
agentKey: c.agentKey || '',
label: c.label || c.roleKey,
charRow: typeof c.charRow === 'number' ? c.charRow : 0,
deskSprite: c.deskSprite || 'desk-main',
face: c.face || 'R',
boss: !!c.boss,
noChar: !!c.noChar,
dock: Array.isArray(c.dock) ? c.dock : [c.deskX+32, c.deskY+80],
roam: Array.isArray(c.roam) ? c.roam : [[c.deskX, c.deskY+120],[c.deskX+60, c.deskY+100]],
deskX: c.deskX, deskY: c.deskY, deskW: c.deskW || 112,
seatX: c.seatX, seatY: c.seatY,
};
stations.push(st);
const m = String(st.key).match(/^desk_(\\d+)$/);
if(m){ const n = parseInt(m[1],10); if(n >= __nextDeskN) __nextDeskN = n + 1; }
});
_rebuildStationIndex();
stations.forEach(buildStation);
(snap.cells||[]).forEach(c=>{
const wrap=__deskWrap[c.roleKey], ch=chars[c.roleKey];
if(wrap){ _applyRot(wrap, c.deskRot); _applyZ(wrap, c.deskZ); }
if(ch){ _applyRot(ch, c.charRot); _applyZ(ch, c.charZ); }
});
(snap.objs||[]).forEach(o=>{
const el = addImg(o.name, o.x, o.y, o.w);
if(o.id){ el.dataset.objId = o.id; }
_applyRot(el, o.rot); _applyZ(el, o.z);
});
return;
}
// v1 — 옛 포맷, in-place patch.
(snap.cells||[]).forEach(c=>{
const wrap=__deskWrap[c.roleKey];
if(wrap){
if(typeof c.deskX==='number') wrap.style.left=c.deskX+'px';
if(typeof c.deskY==='number') wrap.style.top=c.deskY+'px';
if(typeof c.deskW==='number') wrap.style.width=c.deskW+'px';
_applyRot(wrap, c.deskRot);
_applyZ(wrap, c.deskZ);
}
const ch=chars[c.roleKey];
if(ch){
if(typeof c.seatX==='number'){ch.style.left=c.seatX+'px';ch.dataset.homeX=c.seatX;}
if(typeof c.seatY==='number'){ch.style.top=c.seatY+'px';ch.dataset.homeY=c.seatY;}
_applyRot(ch, c.charRot);
_applyZ(ch, c.charZ);
}
const st=stationByKey[c.roleKey];
if(st){
if(typeof c.deskX==='number') st.deskX=c.deskX;
if(typeof c.deskY==='number') st.deskY=c.deskY;
if(typeof c.seatX==='number') st.seatX=c.seatX;
if(typeof c.seatY==='number') st.seatY=c.seatY;
}
});
(snap.objs||[]).forEach(o=>{
let el=null;
if(o.id) el=stage.querySelector('img.obj[data-obj-id="'+o.id+'"]');
if(!el && o.name){
// id 없으면 name으로 첫번째 매칭 — legacy 데이터 호환.
el=stage.querySelector('img.obj[data-obj-name="'+o.name+'"]');
}
if(!el) return;
if(typeof o.x==='number') el.style.left=o.x+'px';
if(typeof o.y==='number') el.style.top=o.y+'px';
if(typeof o.w==='number'){ el.style.width=o.w+'px'; el.dataset.objW=String(o.w); }
_applyRot(el, o.rot);
_applyZ(el, o.z);
});
}
function _setEdit(on){
_editMode=!!on;
document.body.dataset.editMode = _editMode?'true':'false';
document.getElementById('editToolbar').style.display = _editMode?'flex':'none';
document.getElementById('editBtn').textContent = _editMode?'✓ 편집 종료':'✏️ 편집';
if(_editMode){
_snapshotBeforeEdit = _snapshotLayout();
} else {
_snapshotBeforeEdit = null;
// 편집 종료 시 selected 강조 해제.
if(_selected){ _selected.classList.remove('selected'); _selected = null; }
}
_onSelectionChanged();
}
// ── 선택 변화 시 호출 — 속성 패널 / 삭제 버튼 상태 sync ──
function _onSelectionChanged(){
const panel = document.getElementById('propPanel');
const delBtn = document.getElementById('deleteSelBtn');
if(!_editMode || !_selected){
panel.classList.remove('show'); panel.innerHTML='';
if(delBtn) delBtn.disabled = true;
return;
}
// 캐릭터를 선택해도 그 책상의 속성을 편집한다 (캐릭터와 책상은 한 쌍).
let targetForProps = _selected;
if(targetForProps.classList.contains('char')){
const role = Object.keys(chars).find(k=>chars[k]===targetForProps);
if(role && __deskWrap[role]) targetForProps = __deskWrap[role];
}
if(targetForProps.classList.contains('desk')){
if(delBtn) delBtn.disabled = false;
_renderDeskProps(targetForProps);
} else if(_selected.classList.contains('obj')){
if(delBtn) delBtn.disabled = false;
_renderObjProps(_selected);
} else {
panel.classList.remove('show'); panel.innerHTML='';
if(delBtn) delBtn.disabled = true;
}
}
function _renderDeskProps(deskEl){
const role = deskEl.dataset.role;
const st = stationByKey[role]; if(!st) return;
const panel = document.getElementById('propPanel');
panel.classList.add('show');
// 에이전트 매핑 dropdown.
const agentOpts = AGENT_CHOICES.map(a=>'<option value="'+a+'"'+(a===(st.agentKey||'')?' selected':'')+'>'+(a||'(매핑 없음)')+'</option>').join('');
// 책상 sprite picker.
const deskOpts = DESK_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===st.deskSprite?' selected':'')+'>'+s+'</option>').join('');
// charRow 썸네일 picker (idle-r<n>-f0.png).
let thumbs='';
for(let r=0;r<8;r++){
thumbs += '<div class="pp-thumb'+(r===st.charRow?' active':'')+'" data-charrow="'+r+'" title="row '+r+'"><img src="'+png('idle-r'+r+'-f0')+'"></div>';
}
const hasChar = !!chars[role];
panel.innerHTML =
'<h4>책상 속성</h4>'+
'<div class="pp-row"><label>라벨</label><input id="ppLabel" value="'+(st.label||'').replace(/"/g,'&quot;')+'"></div>'+
'<div class="pp-row"><label>에이전트 매핑</label><select id="ppAgent">'+agentOpts+'</select></div>'+
'<div class="pp-row"><label>책상 sprite</label><select id="ppDesk">'+deskOpts+'</select></div>'+
(hasChar ? '' : '<div class="pp-row"><button id="ppAddChar" style="width:100%;padding:6px;background:rgba(16,185,129,.22);border:1px solid rgba(16,185,129,.55);color:#F1F4FB;border-radius:4px;cursor:pointer;font-size:11px">+ 이 책상에 캐릭터 추가</button></div>')+
'<div class="pp-row"><label>착석 캐릭터 (row 0~7)</label><div class="pp-thumbs" id="ppThumbs">'+thumbs+'</div></div>'+
'<div class="pp-row"><label>방향 (앉은 face)</label><select id="ppFace">'+
'<option value="L"'+(st.face==='L'?' selected':'')+'>← Left</option>'+
'<option value="R"'+(st.face==='R'?' selected':'')+'>Right →</option>'+
'<option value="U"'+(st.face==='U'?' selected':'')+'>↑ Up (뒷모습)</option>'+
'<option value="D"'+(st.face==='D'?' selected':'')+'>↓ Down (정면)</option>'+
'</select></div>';
// 핸들러
panel.querySelector('#ppLabel').oninput = (ev)=>{
st.label = ev.target.value;
const lbl = deskEl.querySelector('.label'); if(lbl) lbl.textContent = st.label;
};
panel.querySelector('#ppAgent').onchange = (ev)=>{
st.agentKey = ev.target.value || '';
// CSS data-role 색깔은 agentKey 기준 — 매핑 변경 시 swap.
if(st.agentKey){ deskEl.dataset.agent = st.agentKey; if(chars[role]) chars[role].dataset.agent = st.agentKey; }
else { delete deskEl.dataset.agent; if(chars[role]) delete chars[role].dataset.agent; }
};
panel.querySelector('#ppDesk').onchange = (ev)=>{
st.deskSprite = ev.target.value;
const img = deskEl.querySelector('img'); if(img) img.src = png(st.deskSprite);
};
panel.querySelectorAll('.pp-thumb').forEach(t=>{
t.onclick = ()=>{
const r = parseInt(t.dataset.charrow,10);
st.charRow = r;
if(anim[role]){ anim[role].row = r; }
const ch = chars[role]; if(ch){ ch.dataset.row = r; const img=ch.querySelector('img'); if(img) img.src = png('idle-r'+r+'-f0'); }
panel.querySelectorAll('.pp-thumb').forEach(x=>x.classList.toggle('active', x===t));
};
});
panel.querySelector('#ppFace').onchange = (ev)=>{
st.face = ev.target.value;
if(anim[role]) anim[role].face = st.face;
// 현재 mode 기준 sprite 즉시 다시 그리기 — U/D 도 한 번에 반영.
const a = anim[role]; if(a) setSprite(role, a.mode || 'sit', 0, 0);
};
const addCharBtn = panel.querySelector('#ppAddChar');
if(addCharBtn){
addCharBtn.onclick = ()=>{
st.noChar = false;
addChar(st);
_renderDeskProps(deskEl); // 패널 재렌더 — "캐릭터 추가" 버튼 제거.
};
}
}
function _renderObjProps(el){
const panel = document.getElementById('propPanel');
panel.classList.add('show');
const name = el.dataset.objName || '';
const w = el.dataset.objW || '';
const opts = PROP_SPRITE_CHOICES.map(s=>'<option value="'+s+'"'+(s===name?' selected':'')+'>'+s+'</option>').join('');
panel.innerHTML =
'<h4>프랍 속성</h4>'+
'<div class="pp-row"><label>sprite</label><select id="ppObjName">'+opts+'</select></div>'+
'<div class="pp-row"><label>너비 (px, 비우면 원본)</label><input id="ppObjW" type="number" value="'+w+'" placeholder="원본"></div>';
panel.querySelector('#ppObjName').onchange = (ev)=>{
el.dataset.objName = ev.target.value;
el.src = png(ev.target.value);
};
panel.querySelector('#ppObjW').oninput = (ev)=>{
const v = ev.target.value.trim();
if(v === ''){ el.style.removeProperty('width'); delete el.dataset.objW; }
else { el.style.width = parseFloat(v)+'px'; el.dataset.objW = String(parseFloat(v)); }
};
}
// ── 신규 책상 추가 ──
function _addNewDesk(){
const id = 'desk_'+(__nextDeskN++);
// 기본 위치: stage 중앙 근처, 다른 책상과 안 겹치게 살짝 오프셋.
const baseX = 280 + ((__nextDeskN%5)*16);
const baseY = 260 + ((__nextDeskN%5)*16);
const st = {
key: id, agentKey: '', label: '새 책상', charRow: 0, deskSprite: 'desk-main', face: 'R',
boss: false,
deskX: baseX, deskY: baseY, deskW: 112,
seatX: baseX+4, seatY: baseY+36,
dock: [baseX+32, baseY+80],
roam: [[baseX-20, baseY+120],[baseX+60, baseY+100]],
};
stations.push(st); _rebuildStationIndex();
buildStation(st);
// 새 책상을 자동 선택.
if(_selected) _selected.classList.remove('selected');
_selected = __deskWrap[id]; _selected.classList.add('selected');
_onSelectionChanged();
}
// ── 신규 프랍 추가 — sprite picker 모달 ──
function _openPropPicker(){
const overlay = document.createElement('div');
overlay.className = 'prop-picker';
const box = document.createElement('div');
box.className = 'prop-picker-box';
box.innerHTML = '<h3>프랍 추가 — sprite 선택</h3>'+
'<div class="prop-picker-grid">'+
PROP_SPRITE_CHOICES.map(n=>'<div class="prop-pick" data-name="'+n+'"><img src="'+png(n)+'"><div class="pp-name">'+n+'</div></div>').join('')+
'</div>';
overlay.appendChild(box);
overlay.onclick = (e)=>{ if(e.target === overlay) overlay.remove(); };
box.querySelectorAll('.prop-pick').forEach(p=>{
p.onclick = ()=>{
const name = p.dataset.name;
// stage 중앙 근처에 배치.
const x = 300 + ((__nextObjN%4)*12);
const y = 280 + ((__nextObjN%4)*12);
const el = addImg(name, x, y);
overlay.remove();
// 자동 선택.
if(_selected) _selected.classList.remove('selected');
_selected = el; _selected.classList.add('selected');
_onSelectionChanged();
};
});
document.body.appendChild(overlay);
}
// ── 선택 항목 삭제 ──
// 캐릭터 / 책상 / 프랍 각각 분리해서 처리:
// · 캐릭터 선택 → 캐릭터만 삭제 (책상은 유지). 속성 패널에서 "+ 캐릭터" 로 재추가 가능.
// · 책상 선택 → 책상 + 캐릭터 모두 삭제 (station 자체 제거).
// · 프랍 선택 → 프랍 삭제.
function _deleteSelected(){
if(!_editMode || !_selected) return;
if(_selected.classList.contains('char')){
const role = Object.keys(chars).find(k=>chars[k]===_selected);
if(!role) return;
const st = stationByKey[role];
if(!confirm('"'+(st?.label||role)+'" 책상의 캐릭터를 삭제할까요? 책상은 그대로 남습니다.')) return;
_selected.remove();
delete chars[role]; delete anim[role];
if(st) st.noChar = true;
_selected = null;
_onSelectionChanged();
return;
}
if(_selected.classList.contains('desk')){
const role = _selected.dataset.role;
if(!confirm('"'+(stationByKey[role]?.label||role)+'" 책상을 삭제할까요? 캐릭터도 함께 사라집니다.')) return;
_selected.remove();
if(chars[role]) chars[role].remove();
delete __deskWrap[role]; delete chars[role]; delete anim[role];
const idx = stations.findIndex(s=>s.key===role);
if(idx>=0) stations.splice(idx,1);
_rebuildStationIndex();
_selected = null;
_onSelectionChanged();
return;
}
if(_selected.classList.contains('obj')){
_selected.remove();
_selected = null;
_onSelectionChanged();
return;
}
}
function _findDraggable(el){
while(el && el !== stage){
if(el.classList){
if(el.classList.contains('obj')) return el;
if(el.classList.contains('char')) return el;
if(el.classList.contains('desk')) return el;
}
el = el.parentElement;
}
return null;
}
stage.addEventListener('mousedown', e=>{
if(!_editMode) return;
const target = _findDraggable(e.target);
// 빈 공간 클릭 → 선택 해제.
if(!target){
if(_selected){ _selected.classList.remove('selected'); _selected=null; _onSelectionChanged(); }
return;
}
e.preventDefault();
// 선택 표시 갱신 — 한 번에 하나만 selected. 회전 키가 이 객체에 적용됨.
if(_selected && _selected !== target) _selected.classList.remove('selected');
_selected = target;
target.classList.add('selected');
_onSelectionChanged();
_drag = target;
const rect = stage.getBoundingClientRect();
const tx = parseFloat(target.style.left)||0;
const ty = parseFloat(target.style.top)||0;
_dragDX = e.clientX - rect.left - tx;
_dragDY = e.clientY - rect.top - ty;
target.classList.add('dragging');
});
// 키보드 — 편집 모드에서 선택된 객체에 적용.
// R : 시계방향 90도 회전
// Shift+R : 반시계방향 90도 회전
// ] : 레이어 위로 (z-index +1)
// [ : 레이어 아래로 (z-index -1)
function _bringLayer(el, delta){
const cur = parseFloat(el.dataset.z || '0');
const next = Math.max(-99, Math.min(99, cur + delta));
el.dataset.z = String(next);
el.style.zIndex = String(next);
}
document.addEventListener('keydown', e=>{
if(!_editMode || !_selected) return;
const tag = (e.target && e.target.tagName) || '';
if(tag === 'INPUT' || tag === 'TEXTAREA') return;
const key = e.key.toLowerCase();
if(key === 'r'){
e.preventDefault();
const delta = e.shiftKey ? -90 : 90;
const cur = parseFloat(_selected.dataset.rot || '0');
const next = ((cur + delta) % 360 + 360) % 360;
_selected.dataset.rot = String(next);
_selected.style.transform = next === 0 ? '' : ('rotate(' + next + 'deg)');
return;
}
if(key === ']'){ e.preventDefault(); _bringLayer(_selected, +1); return; }
if(key === '['){ e.preventDefault(); _bringLayer(_selected, -1); return; }
});
document.addEventListener('mousemove', e=>{
if(!_editMode || !_drag) return;
const rect = stage.getBoundingClientRect();
let x = e.clientX - rect.left - _dragDX;
let y = e.clientY - rect.top - _dragDY;
// 4px 격자 snap
x = Math.round(x/4)*4;
y = Math.round(y/4)*4;
// stage 경계 안에 가두기
const sw = stage.offsetWidth, sh = stage.offsetHeight;
const tw = _drag.offsetWidth || 0, th = _drag.offsetHeight || 0;
x = Math.max(0, Math.min(sw - tw, x));
y = Math.max(0, Math.min(sh - th, y));
_drag.style.left = x+'px';
_drag.style.top = y+'px';
});
document.addEventListener('mouseup', ()=>{
if(_drag){ _drag.classList.remove('dragging'); }
if(_editMode && _drag && _drag.classList){
if(_drag.classList.contains('desk')){
// 책상만 새 좌표 저장. 캐릭터는 *그대로 둔다* (분리).
const role = _drag.dataset.role;
const st = stationByKey[role];
if(st){
st.deskX = parseFloat(_drag.style.left);
st.deskY = parseFloat(_drag.style.top);
}
} else if(_drag.classList.contains('char')){
// 캐릭터를 옮긴 경우 — 그 자리를 새 home으로.
const role = Object.keys(chars).find(k=>chars[k]===_drag);
if(role){
const st = stationByKey[role];
const nx = parseFloat(_drag.style.left), ny = parseFloat(_drag.style.top);
if(st){ st.seatX = nx; st.seatY = ny; }
_drag.dataset.homeX = nx;
_drag.dataset.homeY = ny;
}
}
}
_drag = null;
});
document.getElementById('editBtn').addEventListener('click', ()=>{
_setEdit(!_editMode);
});
document.getElementById('layerUpBtn').addEventListener('click', ()=>{
if(_selected) _bringLayer(_selected, +1);
});
document.getElementById('layerDownBtn').addEventListener('click', ()=>{
if(_selected) _bringLayer(_selected, -1);
});
document.getElementById('saveBtn').addEventListener('click', ()=>{
const layout = _snapshotLayout();
try{ vscode.postMessage({type:'savePixelOfficeLayout', value: layout}); }catch{}
_setEdit(false);
});
document.getElementById('cancelBtn').addEventListener('click', ()=>{
// 편집 시작 시 찍어둔 스냅샷으로 되돌리기.
if(_snapshotBeforeEdit) _restoreLayout(_snapshotBeforeEdit);
_setEdit(false);
});
document.getElementById('resetBtn').addEventListener('click', ()=>{
if(!confirm('현재 배치를 버리고 기본 배치로 되돌릴까요?')) return;
try{ vscode.postMessage({type:'resetPixelOfficeLayout'}); }catch{}
});
document.getElementById('addDeskBtn').addEventListener('click', _addNewDesk);
document.getElementById('addPropBtn').addEventListener('click', _openPropPicker);
document.getElementById('deleteSelBtn').addEventListener('click', _deleteSelected);
// Delete / Backspace 키도 같은 동작 — 단 입력 필드 포커스 시 무시.
document.addEventListener('keydown', e=>{
if(!_editMode) return;
const tag = (e.target && e.target.tagName) || '';
if(tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if(e.key === 'Delete' || e.key === 'Backspace'){ e.preventDefault(); _deleteSelected(); }
});
// 저장된 layout 로드 요청 + 응답 처리.
window.addEventListener('message', e=>{
const d = e.data;
if(!d || typeof d !== 'object') return;
if(d.type === 'pixelOfficeLayoutLoaded'){
if(d.value) _restoreLayout(d.value);
}
if(d.type === 'pixelOfficeLayoutSaved'){
if(d.value && d.value.reset){
// 디폴트로 리셋된 경우 — 페이지를 재로딩해서 코드 기본값으로 복귀.
location.reload();
}
}
});
try{ vscode.postMessage({type:'getPixelOfficeLayout'}); }catch{}
})();</script></body></html>`;
}