feat: improve VS Code editor chat actions
Targets active editor chat before falling back to sidebar Adds session open-in-editor action in the VS Code sidebar Uses attachment badges for VS Code file and selection context
This commit is contained in:
@@ -187,6 +187,19 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
void vscodeApi.executeCommand('openchamber.setActiveSession', currentSessionId, activeSessionTitle);
|
void vscodeApi.executeCommand('openchamber.setActiveSession', currentSessionId, activeSessionTitle);
|
||||||
}, [activeSessionTitle, currentSessionId, runtimeApis.vscode]);
|
}, [activeSessionTitle, currentSessionId, runtimeApis.vscode]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (viewMode !== 'editor' || !currentSessionId || !activeSessionTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vscodeApi = runtimeApis.vscode;
|
||||||
|
if (!vscodeApi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void vscodeApi.executeCommand('openchamber.updateSessionEditorTitle', currentSessionId, activeSessionTitle);
|
||||||
|
}, [activeSessionTitle, currentSessionId, runtimeApis.vscode, viewMode]);
|
||||||
|
|
||||||
// If the active session disappears (e.g., deleted), go back to sessions list
|
// If the active session disappears (e.g., deleted), go back to sessions list
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (viewMode === 'editor') {
|
if (viewMode === 'editor') {
|
||||||
@@ -709,7 +722,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('sessions.switcher.openAria')}
|
aria-label={t('sessions.switcher.openAria')}
|
||||||
className="flex min-w-0 flex-1 items-center rounded-md px-1 py-0.5 -my-0.5 text-left transition-colors hover:bg-interactive-hover/60 focus-visible:outline-none focus-visible:bg-interactive-hover/60"
|
className="inline-flex min-w-0 max-w-full items-center rounded-md px-1 py-0.5 -my-0.5 text-left transition-colors hover:bg-interactive-hover/60 focus-visible:outline-none focus-visible:bg-interactive-hover/60"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium truncate" title={title}>{title}</span>
|
<span className="text-sm font-medium truncate" title={title}>{title}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -717,6 +730,7 @@ const VSCodeHeader: React.FC<VSCodeHeaderProps> = ({ title, showBack, onBack, on
|
|||||||
) : (
|
) : (
|
||||||
<h1 className="text-sm font-medium truncate flex-1" title={title}>{title}</h1>
|
<h1 className="text-sm font-medium truncate flex-1" title={title}>{title}</h1>
|
||||||
)}
|
)}
|
||||||
|
<div className="min-w-0 flex-1" />
|
||||||
{onNewSession && (
|
{onNewSession && (
|
||||||
<button
|
<button
|
||||||
onClick={onNewSession}
|
onClick={onNewSession}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
|
import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
|
||||||
import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog';
|
import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog';
|
||||||
import { FusionIcon } from '@/components/icons/FusionIcon';
|
import { FusionIcon } from '@/components/icons/FusionIcon';
|
||||||
|
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||||
|
|
||||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
type Folder = { id: string; name: string; sessionIds: string[] };
|
||||||
|
|
||||||
@@ -261,19 +262,25 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
const isMinimalMode = displayMode === 'minimal';
|
const isMinimalMode = displayMode === 'minimal';
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []);
|
const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []);
|
||||||
|
const runtimeApis = React.useContext(RuntimeAPIContext);
|
||||||
const revealOnHoverClass = isVSCode
|
const revealOnHoverClass = isVSCode
|
||||||
? 'group-hover:opacity-100 group-hover:pointer-events-auto'
|
? 'group-hover:opacity-100 group-hover:pointer-events-auto'
|
||||||
: 'group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto';
|
: 'group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto';
|
||||||
const hideOnHoverClass = isVSCode
|
const hideOnHoverClass = isVSCode
|
||||||
? 'group-hover:opacity-0'
|
? 'group-hover:opacity-0'
|
||||||
: 'group-hover:opacity-0 group-focus-within:opacity-0';
|
: 'group-hover:opacity-0 group-focus-within:opacity-0';
|
||||||
|
const showOpenInEditorAction = isVSCode;
|
||||||
const showQuickArchiveAction = !archivedBucket && !mobileVariant;
|
const showQuickArchiveAction = !archivedBucket && !mobileVariant;
|
||||||
const revealPaddingClass = isMinimalMode
|
const revealPaddingClass = isMinimalMode
|
||||||
? (isVSCode
|
? (isVSCode
|
||||||
? 'group-hover:pr-2'
|
? 'group-hover:pr-2'
|
||||||
: 'group-hover:pr-2 group-focus-within:pr-2')
|
: 'group-hover:pr-2 group-focus-within:pr-2')
|
||||||
: (isVSCode
|
: (isVSCode
|
||||||
? (showQuickArchiveAction ? 'group-hover:pr-12' : 'group-hover:pr-5')
|
? (showQuickArchiveAction && showOpenInEditorAction
|
||||||
|
? 'group-hover:pr-18'
|
||||||
|
: showQuickArchiveAction || showOpenInEditorAction
|
||||||
|
? 'group-hover:pr-12'
|
||||||
|
: 'group-hover:pr-5')
|
||||||
: (showQuickArchiveAction ? 'group-hover:pr-12 group-focus-within:pr-12' : 'group-hover:pr-5 group-focus-within:pr-5'));
|
: (showQuickArchiveAction ? 'group-hover:pr-12 group-focus-within:pr-12' : 'group-hover:pr-5 group-focus-within:pr-5'));
|
||||||
const alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7';
|
const alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7';
|
||||||
const suppressNextSelectRef = React.useRef(false);
|
const suppressNextSelectRef = React.useRef(false);
|
||||||
@@ -609,6 +616,22 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
handleDeleteSession(session, { archivedBucket });
|
handleDeleteSession(session, { archivedBucket });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenInEditorPointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenInEditorMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenInEditorClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void runtimeApis?.vscode?.executeCommand('openchamber.openSessionInEditor', session.id, sessionTitle);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRowSelect = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
const handleRowSelect = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
if (suppressNextSelectRef.current) {
|
if (suppressNextSelectRef.current) {
|
||||||
suppressNextSelectRef.current = false;
|
suppressNextSelectRef.current = false;
|
||||||
@@ -936,6 +959,29 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showOpenInEditorAction ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||||
|
isMinimalMode && !alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6',
|
||||||
|
)}
|
||||||
|
aria-label={t('sessions.sidebar.session.actions.openInEditor')}
|
||||||
|
onPointerDown={handleOpenInEditorPointerDown}
|
||||||
|
onMouseDown={handleOpenInEditorMouseDown}
|
||||||
|
onClick={handleOpenInEditorClick}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Icon name="external-link" className={cn(isMinimalMode && !alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left" sideOffset={8}>
|
||||||
|
{t('sessions.sidebar.session.actions.openInEditor')}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
|
<DropdownMenu open={isMenuOpen} onOpenChange={handleMenuOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ export const dict = {
|
|||||||
'sessions.sidebar.session.menu.exportMarkdown': 'Export Markdown',
|
'sessions.sidebar.session.menu.exportMarkdown': 'Export Markdown',
|
||||||
'sessions.sidebar.session.menu.runFusion': 'Run fusion',
|
'sessions.sidebar.session.menu.runFusion': 'Run fusion',
|
||||||
'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel',
|
'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel',
|
||||||
|
'sessions.sidebar.session.actions.openInEditor': 'Open in Editor',
|
||||||
'sessions.sidebar.session.menu.betaBadge': 'beta',
|
'sessions.sidebar.session.menu.betaBadge': 'beta',
|
||||||
'sessions.sidebar.session.menu.label': 'Session menu',
|
'sessions.sidebar.session.menu.label': 'Session menu',
|
||||||
'sessions.sidebar.session.untitled': 'Untitled Session',
|
'sessions.sidebar.session.untitled': 'Untitled Session',
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
|
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
|
||||||
"sessions.sidebar.session.menu.runFusion": "Ejecutar fusion",
|
"sessions.sidebar.session.menu.runFusion": "Ejecutar fusion",
|
||||||
"sessions.sidebar.session.menu.openInSidePanel": "Abrir en panel lateral",
|
"sessions.sidebar.session.menu.openInSidePanel": "Abrir en panel lateral",
|
||||||
|
"sessions.sidebar.session.actions.openInEditor": "Abrir en el editor",
|
||||||
"sessions.sidebar.session.menu.betaBadge": "beta",
|
"sessions.sidebar.session.menu.betaBadge": "beta",
|
||||||
"sessions.sidebar.session.menu.label": "Menú de sesión",
|
"sessions.sidebar.session.menu.label": "Menú de sesión",
|
||||||
"sessions.sidebar.session.untitled": "Sesión sin título",
|
"sessions.sidebar.session.untitled": "Sesión sin título",
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.session.menu.exportMarkdown': 'Markdown 내보내기',
|
'sessions.sidebar.session.menu.exportMarkdown': 'Markdown 내보내기',
|
||||||
'sessions.sidebar.session.menu.runFusion': 'fusion 실행',
|
'sessions.sidebar.session.menu.runFusion': 'fusion 실행',
|
||||||
'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기',
|
'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기',
|
||||||
|
'sessions.sidebar.session.actions.openInEditor': '편집기에서 열기',
|
||||||
'sessions.sidebar.session.menu.betaBadge': 'beta',
|
'sessions.sidebar.session.menu.betaBadge': 'beta',
|
||||||
'sessions.sidebar.session.menu.label': '세션 메뉴',
|
'sessions.sidebar.session.menu.label': '세션 메뉴',
|
||||||
'sessions.sidebar.session.untitled': '제목 없는 세션',
|
'sessions.sidebar.session.untitled': '제목 없는 세션',
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.session.menu.exportMarkdown': 'Eksportuj Markdown',
|
'sessions.sidebar.session.menu.exportMarkdown': 'Eksportuj Markdown',
|
||||||
'sessions.sidebar.session.menu.runFusion': 'Uruchom fusion',
|
'sessions.sidebar.session.menu.runFusion': 'Uruchom fusion',
|
||||||
'sessions.sidebar.session.menu.openInSidePanel': 'Otwórz w panelu bocznym',
|
'sessions.sidebar.session.menu.openInSidePanel': 'Otwórz w panelu bocznym',
|
||||||
|
'sessions.sidebar.session.actions.openInEditor': 'Otwórz w edytorze',
|
||||||
'sessions.sidebar.session.menu.betaBadge': 'beta',
|
'sessions.sidebar.session.menu.betaBadge': 'beta',
|
||||||
'sessions.sidebar.session.menu.label': 'Menu sesji',
|
'sessions.sidebar.session.menu.label': 'Menu sesji',
|
||||||
'sessions.sidebar.session.untitled': 'Nienazwana Sesja',
|
'sessions.sidebar.session.untitled': 'Nienazwana Sesja',
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
|
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
|
||||||
"sessions.sidebar.session.menu.runFusion": "Executar fusion",
|
"sessions.sidebar.session.menu.runFusion": "Executar fusion",
|
||||||
"sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral",
|
"sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral",
|
||||||
|
"sessions.sidebar.session.actions.openInEditor": "Abrir no editor",
|
||||||
"sessions.sidebar.session.menu.betaBadge": "beta",
|
"sessions.sidebar.session.menu.betaBadge": "beta",
|
||||||
"sessions.sidebar.session.menu.label": "Menu da sessão",
|
"sessions.sidebar.session.menu.label": "Menu da sessão",
|
||||||
"sessions.sidebar.session.untitled": "Sessão sem título",
|
"sessions.sidebar.session.untitled": "Sessão sem título",
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.session.menu.exportMarkdown": "Експорт Markdown",
|
"sessions.sidebar.session.menu.exportMarkdown": "Експорт Markdown",
|
||||||
"sessions.sidebar.session.menu.runFusion": "Запустити fusion",
|
"sessions.sidebar.session.menu.runFusion": "Запустити fusion",
|
||||||
"sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі",
|
"sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі",
|
||||||
|
"sessions.sidebar.session.actions.openInEditor": "Відкрити в редакторі",
|
||||||
"sessions.sidebar.session.menu.betaBadge": "бета-версія",
|
"sessions.sidebar.session.menu.betaBadge": "бета-версія",
|
||||||
"sessions.sidebar.session.menu.label": "Меню сесії",
|
"sessions.sidebar.session.menu.label": "Меню сесії",
|
||||||
"sessions.sidebar.session.untitled": "Сесія без назви",
|
"sessions.sidebar.session.untitled": "Сесія без назви",
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.session.menu.exportMarkdown': '导出 Markdown',
|
'sessions.sidebar.session.menu.exportMarkdown': '导出 Markdown',
|
||||||
'sessions.sidebar.session.menu.runFusion': '运行 fusion',
|
'sessions.sidebar.session.menu.runFusion': '运行 fusion',
|
||||||
'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开',
|
'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开',
|
||||||
|
'sessions.sidebar.session.actions.openInEditor': '在编辑器中打开',
|
||||||
'sessions.sidebar.session.menu.betaBadge': 'beta',
|
'sessions.sidebar.session.menu.betaBadge': 'beta',
|
||||||
'sessions.sidebar.session.menu.label': '会话菜单',
|
'sessions.sidebar.session.menu.label': '会话菜单',
|
||||||
'sessions.sidebar.session.untitled': '未命名会话',
|
'sessions.sidebar.session.untitled': '未命名会话',
|
||||||
|
|||||||
@@ -199,6 +199,37 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public addContextSelection(selection: { filePath: string; filename: string; text: string }) {
|
||||||
|
if (!this._view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._view.show(true);
|
||||||
|
this._view.webview.postMessage({
|
||||||
|
type: 'command',
|
||||||
|
command: 'addContextSelection',
|
||||||
|
payload: selection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public addFileAttachments(files: Array<{ filePath: string; fileName: string; fileSize: number | null }>) {
|
||||||
|
if (!this._view) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedFiles = files.filter((entry) => entry.filePath.trim().length > 0 && entry.fileName.trim().length > 0);
|
||||||
|
if (cleanedFiles.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._view.show(true);
|
||||||
|
this._view.webview.postMessage({
|
||||||
|
type: 'command',
|
||||||
|
command: 'addFileAttachments',
|
||||||
|
payload: { files: cleanedFiles },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public addFileMentions(paths: string[]) {
|
public addFileMentions(paths: string[]) {
|
||||||
if (!this._view) {
|
if (!this._view) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -13,6 +13,26 @@ type SessionPanelState = {
|
|||||||
sseStreams: Map<string, AbortController>;
|
sseStreams: Map<string, AbortController>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ActiveEditorFilePayload = {
|
||||||
|
filePath: string;
|
||||||
|
fileName: string;
|
||||||
|
relativePath: string;
|
||||||
|
fileSize: number | null;
|
||||||
|
selection: { startLine: number; endLine: number; text: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSameActiveEditorFilePayload = (a: ActiveEditorFilePayload | null, b: ActiveEditorFilePayload | null): boolean => {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a.filePath === b.filePath
|
||||||
|
&& a.fileName === b.fileName
|
||||||
|
&& a.relativePath === b.relativePath
|
||||||
|
&& a.fileSize === b.fileSize
|
||||||
|
&& a.selection?.startLine === b.selection?.startLine
|
||||||
|
&& a.selection?.endLine === b.selection?.endLine
|
||||||
|
&& a.selection?.text === b.selection?.text;
|
||||||
|
};
|
||||||
|
|
||||||
export class SessionEditorPanelProvider {
|
export class SessionEditorPanelProvider {
|
||||||
public static readonly viewType = 'openchamber.sessionEditor';
|
public static readonly viewType = 'openchamber.sessionEditor';
|
||||||
|
|
||||||
@@ -20,6 +40,10 @@ export class SessionEditorPanelProvider {
|
|||||||
private _cachedError?: string;
|
private _cachedError?: string;
|
||||||
private _sseCounter = 0;
|
private _sseCounter = 0;
|
||||||
private _panels = new Map<string, SessionPanelState>();
|
private _panels = new Map<string, SessionPanelState>();
|
||||||
|
private _lastActivePanelId: string | null = null;
|
||||||
|
private _broadcastSelectionDebounce: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private _clearActiveEditorFileTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private _lastActiveEditorFilePayload: ActiveEditorFilePayload | null = null;
|
||||||
private readonly _webviewDevServerUrl: string | null;
|
private readonly _webviewDevServerUrl: string | null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -28,6 +52,11 @@ export class SessionEditorPanelProvider {
|
|||||||
private readonly _openCodeManager?: OpenCodeManager
|
private readonly _openCodeManager?: OpenCodeManager
|
||||||
) {
|
) {
|
||||||
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
|
this._webviewDevServerUrl = resolveWebviewDevServerUrl(this._context);
|
||||||
|
|
||||||
|
this._context.subscriptions.push(
|
||||||
|
vscode.window.onDidChangeActiveTextEditor(() => void this._broadcastActiveEditorFile()),
|
||||||
|
vscode.window.onDidChangeTextEditorSelection(() => this._scheduleBroadcast()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public createOrShowNewSession(): void {
|
public createOrShowNewSession(): void {
|
||||||
@@ -78,22 +107,40 @@ export class SessionEditorPanelProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this._panels.set(panelId, state);
|
this._panels.set(panelId, state);
|
||||||
|
this._lastActivePanelId = panelId;
|
||||||
|
|
||||||
panel.webview.html = this._getHtmlForWebview(panel.webview, initialSessionId);
|
panel.webview.html = this._getHtmlForWebview(panel.webview, initialSessionId);
|
||||||
|
|
||||||
void this.updateTheme(vscode.window.activeColorTheme.kind);
|
void this.updateTheme(vscode.window.activeColorTheme.kind);
|
||||||
this._sendCachedStateToPanel(state);
|
this._sendCachedStateToPanel(state);
|
||||||
|
void this._broadcastActiveEditorFile();
|
||||||
|
|
||||||
panel.onDidDispose(() => {
|
panel.onDidDispose(() => {
|
||||||
this._disposePanel(panelId);
|
this._disposePanel(panelId);
|
||||||
}, null, this._context.subscriptions);
|
}, null, this._context.subscriptions);
|
||||||
|
|
||||||
|
panel.onDidChangeViewState((event) => {
|
||||||
|
if (event.webviewPanel.active) {
|
||||||
|
this._lastActivePanelId = panelId;
|
||||||
|
}
|
||||||
|
}, null, this._context.subscriptions);
|
||||||
|
|
||||||
panel.webview.onDidReceiveMessage(async (message: BridgeRequest) => {
|
panel.webview.onDidReceiveMessage(async (message: BridgeRequest) => {
|
||||||
if (message.type === 'restartApi') {
|
if (message.type === 'restartApi') {
|
||||||
await this._openCodeManager?.restart();
|
await this._openCodeManager?.restart();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === 'vscode:command') {
|
||||||
|
const { command, args } = (message.payload || {}) as { command?: unknown; args?: unknown[] };
|
||||||
|
if (command === 'openchamber.updateSessionEditorTitle') {
|
||||||
|
const title = typeof args?.[1] === 'string' && args[1].trim().length > 0 ? args[1].trim() : 'Session';
|
||||||
|
state.panel.title = title;
|
||||||
|
state.panel.webview.postMessage({ id: message.id, type: message.type, success: true, data: { result: true } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === 'api:sse:start') {
|
if (message.type === 'api:sse:start') {
|
||||||
const response = await this._startSseProxy(message, state);
|
const response = await this._startSseProxy(message, state);
|
||||||
state.panel.webview.postMessage(response);
|
state.panel.webview.postMessage(response);
|
||||||
@@ -159,6 +206,75 @@ export class SessionEditorPanelProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _getActivePanelEntry(): SessionPanelState | null {
|
||||||
|
const activeEntry = Array.from(this._panels.entries()).find(([, entry]) => entry.panel.active);
|
||||||
|
const panelId = activeEntry?.[0] ?? this._lastActivePanelId;
|
||||||
|
if (!panelId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._panels.get(panelId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addContextSelectionToActivePanel(selection: { filePath: string; filename: string; text: string }): boolean {
|
||||||
|
if (!selection.filePath.trim() || !selection.filename.trim() || !selection.text.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this._getActivePanelEntry();
|
||||||
|
if (!entry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.panel.reveal(entry.panel.viewColumn ?? vscode.ViewColumn.Active, true);
|
||||||
|
void entry.panel.webview.postMessage({
|
||||||
|
type: 'command',
|
||||||
|
command: 'addContextSelection',
|
||||||
|
payload: selection,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public createSessionWithPromptInActivePanel(prompt: string): boolean {
|
||||||
|
if (!prompt.trim()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this._getActivePanelEntry();
|
||||||
|
if (!entry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.panel.reveal(entry.panel.viewColumn ?? vscode.ViewColumn.Active, true);
|
||||||
|
void entry.panel.webview.postMessage({
|
||||||
|
type: 'command',
|
||||||
|
command: 'createSessionWithPrompt',
|
||||||
|
payload: { prompt },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addFileAttachmentsToActivePanel(files: Array<{ filePath: string; fileName: string; fileSize: number | null }>): boolean {
|
||||||
|
const cleanedFiles = files.filter((entry) => entry.filePath.trim().length > 0 && entry.fileName.trim().length > 0);
|
||||||
|
|
||||||
|
if (cleanedFiles.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = this._getActivePanelEntry();
|
||||||
|
if (!entry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.panel.reveal(entry.panel.viewColumn ?? vscode.ViewColumn.Active, true);
|
||||||
|
void entry.panel.webview.postMessage({
|
||||||
|
type: 'command',
|
||||||
|
command: 'addFileAttachments',
|
||||||
|
payload: { files: cleanedFiles },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private _sendCachedStateToPanel(entry: SessionPanelState) {
|
private _sendCachedStateToPanel(entry: SessionPanelState) {
|
||||||
entry.panel.webview.postMessage({
|
entry.panel.webview.postMessage({
|
||||||
type: 'connectionStatus',
|
type: 'connectionStatus',
|
||||||
@@ -172,6 +288,93 @@ export class SessionEditorPanelProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _postCommandToPanels(command: string, payload: unknown): void {
|
||||||
|
for (const entry of this._panels.values()) {
|
||||||
|
entry.panel.webview.postMessage({
|
||||||
|
type: 'command',
|
||||||
|
command,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _scheduleBroadcast(): void {
|
||||||
|
if (this._broadcastSelectionDebounce !== undefined) {
|
||||||
|
clearTimeout(this._broadcastSelectionDebounce);
|
||||||
|
}
|
||||||
|
this._broadcastSelectionDebounce = setTimeout(() => {
|
||||||
|
this._broadcastSelectionDebounce = undefined;
|
||||||
|
void this._broadcastActiveEditorFile();
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _scheduleClearActiveEditorFile(): void {
|
||||||
|
if (this._clearActiveEditorFileTimer !== undefined) {
|
||||||
|
clearTimeout(this._clearActiveEditorFileTimer);
|
||||||
|
}
|
||||||
|
this._clearActiveEditorFileTimer = setTimeout(() => {
|
||||||
|
this._clearActiveEditorFileTimer = undefined;
|
||||||
|
if (this._panels.size === 0 || this._lastActiveEditorFilePayload === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._lastActiveEditorFilePayload = null;
|
||||||
|
this._postCommandToPanels('activeEditorFile', null);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _broadcastActiveEditorFile(): Promise<void> {
|
||||||
|
if (this._panels.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
if (!editor || editor.document.uri.scheme !== 'file') {
|
||||||
|
this._scheduleClearActiveEditorFile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorUri = editor.document.uri;
|
||||||
|
const editorUriKey = editorUri.toString();
|
||||||
|
|
||||||
|
if (this._clearActiveEditorFileTimer !== undefined) {
|
||||||
|
clearTimeout(this._clearActiveEditorFileTimer);
|
||||||
|
this._clearActiveEditorFileTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = normalizeWindowsDriveLetter(editorUri.fsPath);
|
||||||
|
const fileName = editorUri.fsPath.replace(/\\/g, '/').split('/').pop() || '';
|
||||||
|
const relativePath = vscode.workspace.asRelativePath(editorUri, false);
|
||||||
|
|
||||||
|
let fileSize: number | null = null;
|
||||||
|
try {
|
||||||
|
const stat = await vscode.workspace.fs.stat(editorUri);
|
||||||
|
fileSize = stat.size;
|
||||||
|
} catch {
|
||||||
|
// File may not be saved yet or inaccessible.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vscode.window.activeTextEditor?.document.uri.toString() !== editorUriKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selection: ActiveEditorFilePayload['selection'] = null;
|
||||||
|
if (!editor.selection.isEmpty) {
|
||||||
|
selection = {
|
||||||
|
startLine: editor.selection.start.line + 1,
|
||||||
|
endLine: editor.selection.end.line + 1,
|
||||||
|
text: editor.document.getText(editor.selection),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: ActiveEditorFilePayload = { filePath, fileName, relativePath, fileSize, selection };
|
||||||
|
if (isSameActiveEditorFilePayload(this._lastActiveEditorFilePayload, payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastActiveEditorFilePayload = payload;
|
||||||
|
this._postCommandToPanels('activeEditorFile', payload);
|
||||||
|
}
|
||||||
|
|
||||||
private _disposePanel(sessionId: string) {
|
private _disposePanel(sessionId: string) {
|
||||||
const entry = this._panels.get(sessionId);
|
const entry = this._panels.get(sessionId);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
@@ -182,6 +385,9 @@ export class SessionEditorPanelProvider {
|
|||||||
entry.sseStreams.clear();
|
entry.sseStreams.clear();
|
||||||
|
|
||||||
this._panels.delete(sessionId);
|
this._panels.delete(sessionId);
|
||||||
|
if (this._lastActivePanelId === sessionId) {
|
||||||
|
this._lastActivePanelId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
|
private _buildSseHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||||
|
|||||||
@@ -288,20 +288,24 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
|
|
||||||
// Get file info for context
|
// Get file info for context
|
||||||
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
|
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
|
||||||
const languageId = editor.document.languageId;
|
|
||||||
|
|
||||||
// Get line numbers (1-based for display)
|
// Get line numbers (1-based for display)
|
||||||
const startLine = selection.start.line + 1;
|
const startLine = selection.start.line + 1;
|
||||||
const endLine = selection.end.line + 1;
|
const endLine = selection.end.line + 1;
|
||||||
const lineRange = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
|
const lineRange = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
|
||||||
|
|
||||||
// Format as file path with line numbers, followed by markdown code block
|
const filename = `${editor.document.fileName.split(/[\\/]/).pop() || filePath}:${lineRange}`;
|
||||||
const contextText = `${filePath}:${lineRange}\n\`\`\`${languageId}\n${selectedText}\n\`\`\``;
|
const contextSelection = {
|
||||||
|
filePath: editor.document.uri.fsPath,
|
||||||
|
filename,
|
||||||
|
text: selectedText,
|
||||||
|
};
|
||||||
|
|
||||||
if (!(await revealChatViewForPayload())) {
|
if (!sessionEditorProvider?.addContextSelectionToActivePanel(contextSelection)) {
|
||||||
return;
|
if (!(await revealChatViewForPayload())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatViewProvider?.addContextSelection(contextSelection);
|
||||||
}
|
}
|
||||||
chatViewProvider?.addTextToInput(contextText);
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -322,7 +326,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uniqueUris = Array.from(new Map(uriCandidates.map((uri) => [uri.toString(), uri])).values());
|
const uniqueUris = Array.from(new Map(uriCandidates.map((uri) => [uri.toString(), uri])).values());
|
||||||
const mentionPaths: string[] = [];
|
const attachedFiles: Array<{ filePath: string; fileName: string; fileSize: number | null }> = [];
|
||||||
const skippedEntries: string[] = [];
|
const skippedEntries: string[] = [];
|
||||||
|
|
||||||
for (const uri of uniqueUris) {
|
for (const uri of uniqueUris) {
|
||||||
@@ -342,23 +346,33 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativePath = vscode.workspace.asRelativePath(uri, false).replace(/\\/g, '/').trim();
|
const filePath = uri.fsPath.trim();
|
||||||
if (!relativePath) {
|
const fileName = uri.fsPath.replace(/\\/g, '/').split('/').pop() || vscode.workspace.asRelativePath(uri, false).replace(/\\/g, '/').trim();
|
||||||
|
if (!filePath || !fileName) {
|
||||||
skippedEntries.push(uri.fsPath || uri.toString());
|
skippedEntries.push(uri.fsPath || uri.toString());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
mentionPaths.push(relativePath);
|
let fileSize: number | null = null;
|
||||||
|
try {
|
||||||
|
const stat = await vscode.workspace.fs.stat(uri);
|
||||||
|
fileSize = stat.size;
|
||||||
|
} catch {
|
||||||
|
fileSize = null;
|
||||||
|
}
|
||||||
|
attachedFiles.push({ filePath, fileName, fileSize });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mentionPaths.length === 0) {
|
if (attachedFiles.length === 0) {
|
||||||
vscode.window.showWarningMessage('OpenChamber: No file selected to mention');
|
vscode.window.showWarningMessage('OpenChamber: No file selected to mention');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await revealChatViewForPayload())) {
|
if (!sessionEditorProvider?.addFileAttachmentsToActivePanel(attachedFiles)) {
|
||||||
return;
|
if (!(await revealChatViewForPayload())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatViewProvider?.addFileAttachments(attachedFiles);
|
||||||
}
|
}
|
||||||
chatViewProvider?.addFileMentions(mentionPaths);
|
|
||||||
|
|
||||||
if (skippedEntries.length > 0) {
|
if (skippedEntries.length > 0) {
|
||||||
vscode.window.showInformationMessage('OpenChamber: Some selected entries were skipped (folders or unsupported resources)');
|
vscode.window.showInformationMessage('OpenChamber: Some selected entries were skipped (folders or unsupported resources)');
|
||||||
@@ -392,10 +406,12 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
prompt = `Explain the following Code / Text:\n\n${filePath}`;
|
prompt = `Explain the following Code / Text:\n\n${filePath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await revealChatViewForPayload())) {
|
if (!sessionEditorProvider?.createSessionWithPromptInActivePanel(prompt)) {
|
||||||
return;
|
if (!(await revealChatViewForPayload())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatViewProvider?.createNewSessionWithPrompt(prompt);
|
||||||
}
|
}
|
||||||
chatViewProvider?.createNewSessionWithPrompt(prompt);
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -423,10 +439,12 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
|
|
||||||
const prompt = `Improve the following Code:\n\n${filePath}:${lineRange}\n\`\`\`${languageId}\n${selectedText}\n\`\`\``;
|
const prompt = `Improve the following Code:\n\n${filePath}:${lineRange}\n\`\`\`${languageId}\n${selectedText}\n\`\`\``;
|
||||||
|
|
||||||
if (!(await revealChatViewForPayload())) {
|
if (!sessionEditorProvider?.createSessionWithPromptInActivePanel(prompt)) {
|
||||||
return;
|
if (!(await revealChatViewForPayload())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatViewProvider?.createNewSessionWithPrompt(prompt);
|
||||||
}
|
}
|
||||||
chatViewProvider?.createNewSessionWithPrompt(prompt);
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1159,12 +1159,21 @@ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|||||||
return originalFetch(input as RequestInfo, init);
|
return originalFetch(input as RequestInfo, init);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen for addToContext command from extension
|
onCommand('addContextSelection', (payload) => {
|
||||||
onCommand('addToContext', (payload) => {
|
const { filePath, filename, text } = payload as { filePath?: unknown; filename?: unknown; text?: unknown };
|
||||||
const { text } = payload as { text: string };
|
if (typeof filePath !== 'string' || typeof filename !== 'string' || typeof text !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedPath = filePath.trim();
|
||||||
|
const trimmedFilename = filename.trim();
|
||||||
|
if (!trimmedPath || !trimmedFilename || !text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
import('@/sync/input-store').then(({ useInputStore }) => {
|
import('@/sync/input-store').then(({ useInputStore }) => {
|
||||||
useInputStore.getState().setPendingInputText(text, 'append');
|
const file = new File([new Blob([text], { type: 'text/plain' })], trimmedFilename, { type: 'text/plain' });
|
||||||
|
void useInputStore.getState().addVSCodeSelectionAttachment(trimmedPath, file);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1188,6 +1197,33 @@ onCommand('addFileMentions', (payload) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onCommand('addFileAttachments', (payload) => {
|
||||||
|
const rawFiles = Array.isArray((payload as { files?: unknown[] })?.files)
|
||||||
|
? (payload as { files: unknown[] }).files
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const files = rawFiles
|
||||||
|
.map((entry) => {
|
||||||
|
const record = entry as { filePath?: unknown; fileName?: unknown; fileSize?: unknown };
|
||||||
|
const filePath = typeof record.filePath === 'string' ? record.filePath.trim() : '';
|
||||||
|
const fileName = typeof record.fileName === 'string' ? record.fileName.trim() : '';
|
||||||
|
const fileSize = typeof record.fileSize === 'number' && Number.isFinite(record.fileSize) ? record.fileSize : null;
|
||||||
|
return filePath && fileName ? { filePath, fileName, fileSize } : null;
|
||||||
|
})
|
||||||
|
.filter((entry): entry is { filePath: string; fileName: string; fileSize: number | null } => entry !== null);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
import('@/sync/input-store').then(({ useInputStore }) => {
|
||||||
|
const inputStore = useInputStore.getState();
|
||||||
|
for (const file of files) {
|
||||||
|
inputStore.addVSCodeFileAttachment(file.filePath, file.fileName, file.fileSize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Listen for createSessionWithPrompt command from extension (Explain, Improve Code)
|
// Listen for createSessionWithPrompt command from extension (Explain, Improve Code)
|
||||||
onCommand('createSessionWithPrompt', (payload) => {
|
onCommand('createSessionWithPrompt', (payload) => {
|
||||||
const { prompt } = payload as { prompt: string };
|
const { prompt } = payload as { prompt: string };
|
||||||
@@ -1200,13 +1236,14 @@ onCommand('createSessionWithPrompt', (payload) => {
|
|||||||
const sessionStore = useSessionUIStore.getState();
|
const sessionStore = useSessionUIStore.getState();
|
||||||
const configStore = useConfigStore.getState();
|
const configStore = useConfigStore.getState();
|
||||||
|
|
||||||
// Open a new session draft first
|
|
||||||
sessionStore.openNewSessionDraft();
|
|
||||||
|
|
||||||
// Get current provider/model/agent configuration
|
// Get current provider/model/agent configuration
|
||||||
const { currentProviderId, currentModelId, currentAgentName } = configStore;
|
const { currentProviderId, currentModelId, currentAgentName } = configStore;
|
||||||
|
|
||||||
if (currentProviderId && currentModelId) {
|
if (currentProviderId && currentModelId) {
|
||||||
|
if (!sessionStore.currentSessionId) {
|
||||||
|
sessionStore.openNewSessionDraft();
|
||||||
|
}
|
||||||
|
|
||||||
// Send the message - this will create the session from the draft and send
|
// Send the message - this will create the session from the draft and send
|
||||||
sessionStore.sendMessage(
|
sessionStore.sendMessage(
|
||||||
prompt,
|
prompt,
|
||||||
|
|||||||
Reference in New Issue
Block a user