Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions extensions/github1s-ai/assets/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,10 @@ summary:focus-visible,

.attachment-name {
gap: 3px;
padding: 0;
color: inherit;
background: transparent;
border: 0;
}

.attachment-label {
Expand Down
28 changes: 11 additions & 17 deletions extensions/github1s-ai/src/chat-view.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import * as vscode from 'vscode';

import {
parseMarkdownHighlightRequest,
parseViewEvent,
type MarkdownHighlightRequest,
type ViewMessage,
} from '@/common/protocol';
import { parseViewEvent, type ViewEvent, type ViewMessage } from '@/common/protocol';
import { Controllers } from '@/controllers';
import { currentFile, currentSelection } from '@/controllers/context';
import { computeSyntaxHighlighting, onDidChangeSyntaxHighlighting } from '@/helpers/highlighting';
Expand Down Expand Up @@ -78,18 +73,17 @@ export class ChatViewProvider implements vscode.WebviewViewProvider, vscode.Disp
void view.webview.postMessage({ type: 'app.setState', state } satisfies ViewMessage);
}),
view.webview.onDidReceiveMessage((event) => {
const highlightRequest = parseMarkdownHighlightRequest(event);
if (highlightRequest) {
void this.respondWithSyntaxHighlighting(view.webview, highlightRequest).catch(() => undefined);
return;
}
const parsed = parseViewEvent(event);
if (parsed) {
this.controllers
.emit(parsed)
.then((message) => message && view.webview.postMessage(message))
.catch(() => undefined);
if (!parsed) return;
if (parsed.type === 'markdown.highlight') {
// Bypass Controllers.emit to avoid syncing the full view state for each code block.
void this.respondWithSyntaxHighlighting(view.webview, parsed).catch(() => undefined);
return;
}
this.controllers
.emit(parsed)
.then((message) => message && view.webview.postMessage(message))
.catch(() => undefined);
}),
);
const highlightingListener = onDidChangeSyntaxHighlighting(() => {
Expand Down Expand Up @@ -137,7 +131,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider, vscode.Disp

private async respondWithSyntaxHighlighting(
webview: vscode.Webview,
request: MarkdownHighlightRequest,
request: ViewEvent<'markdown.highlight'>,
): Promise<void> {
const highlighting = await computeSyntaxHighlighting(request.source, request.languageId);
await webview.postMessage({
Expand Down
58 changes: 24 additions & 34 deletions extensions/github1s-ai/src/common/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,13 @@ export interface ViewState {
conversations: ConversationSummary[];
}

export interface MarkdownHighlightRequest {
type: 'markdown.highlight';
requestId: string;
languageId: string;
source: string;
}

type AllViewEvents =
| { type: 'app.ready' }
| { type: 'app.newChat' }
| { type: 'app.openChat' }
| { type: 'app.openHistory' }
| { type: 'app.openSettings' }
| { type: 'app.openFile'; source: string }
| { type: 'chat.send'; text: string }
| { type: 'chat.runQuickAction'; action: ChatQuickAction }
| { type: 'chat.addContextAttachment'; action: ContextAttachmentAction }
Expand All @@ -55,6 +49,7 @@ type AllViewEvents =
| { type: 'chat.cancel' }
| { type: 'history.selectConversation'; id: string }
| { type: 'history.deleteConversation'; id: string }
| { type: 'markdown.highlight'; requestId: string; languageId: string; source: string }
| { type: 'settings.selectModelConfig'; id: string }
| { type: 'settings.clearFeedback' }
| { type: 'settings.exportHistory' }
Expand All @@ -66,39 +61,12 @@ type AllViewEvents =

export type ViewEvent<K extends AllViewEvents['type'] = AllViewEvents['type']> = Extract<AllViewEvents, { type: K }>;

export type ViewRequest = ViewEvent | MarkdownHighlightRequest;

export type ViewMessage =
| { type: 'app.setState'; state: ViewState }
| { type: 'settings.historyExport'; filename: string; content: string }
| { type: 'markdown.highlightResult'; requestId: string; highlighting?: SyntaxHighlightingData }
| { type: 'markdown.highlightingChanged' };

export const parseMarkdownHighlightRequest = (value: unknown): MarkdownHighlightRequest | undefined => {
if (!isPlainObject(value)) return undefined;
const request = value as Record<string, unknown>;
if (
!hasExactKeys(request, ['type', 'requestId', 'languageId', 'source']) ||
request.type !== 'markdown.highlight' ||
typeof request.requestId !== 'string' ||
request.requestId.length === 0 ||
request.requestId.length > 100 ||
typeof request.languageId !== 'string' ||
request.languageId.length === 0 ||
request.languageId.length > 100 ||
typeof request.source !== 'string' ||
request.source.length > MAX_SYNTAX_HIGHLIGHT_SOURCE_LENGTH
) {
return undefined;
}
return {
type: request.type,
requestId: request.requestId,
languageId: request.languageId,
source: request.source,
};
};

export const parseViewEvent = (value: unknown): ViewEvent | undefined => {
if (!isPlainObject(value)) return undefined;
const event = value as Record<string, unknown>;
Expand All @@ -115,6 +83,9 @@ export const parseViewEvent = (value: unknown): ViewEvent | undefined => {
case 'settings.clearAllData':
return hasExactKeys(event, ['type']) ? { type: event.type } : undefined;

case 'app.openFile':
return exactString(event, 'source') ? { type: event.type, source: event.source as string } : undefined;

case 'chat.send':
return hasExactKeys(event, ['type', 'text']) && typeof event.text === 'string'
? { type: event.type, text: event.text }
Expand Down Expand Up @@ -147,6 +118,25 @@ export const parseViewEvent = (value: unknown): ViewEvent | undefined => {
? { type: event.type, action: event.action }
: undefined;

case 'markdown.highlight':
if (
!hasExactKeys(event, ['type', 'requestId', 'languageId', 'source']) ||
typeof event.requestId !== 'string' ||
event.requestId.length === 0 ||
typeof event.languageId !== 'string' ||
event.languageId.length === 0 ||
typeof event.source !== 'string' ||
event.source.length > MAX_SYNTAX_HIGHLIGHT_SOURCE_LENGTH
) {
return undefined;
}
return {
type: event.type,
requestId: event.requestId,
languageId: event.languageId,
source: event.source,
};

case 'settings.saveModelConfig': {
if (!hasExactKeys(event, ['type', 'config'])) return undefined;
return isModelConfigInput(event.config) ? { type: event.type, config: event.config } : undefined;
Expand Down
15 changes: 15 additions & 0 deletions extensions/github1s-ai/src/controllers/app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as vscode from 'vscode';

import { markStreamingMessagesUnknown } from '@/common/conversation';
import type { ViewEvent } from '@/common/protocol';
import type { RuntimeState } from '@/common/state';
Expand Down Expand Up @@ -47,6 +49,19 @@ export class AppController extends Controller {
await this.openPage('settings');
}

@Controller.handler('app.openFile')
async handleOpenFile(event: ViewEvent<'app.openFile'>): Promise<void> {
const uri = vscode.Uri.parse(event.source);
const match = /^L(\d+):(\d+)-L(\d+):(\d+)$/.exec(uri.fragment);
const positions = match?.slice(1).map(Number);
let selection: vscode.Range | undefined;
if (positions && positions.every((position) => position >= 1)) {
const [startLine, startCharacter, endLine, endCharacter] = positions;
selection = new vscode.Range(startLine - 1, startCharacter - 1, endLine - 1, endCharacter - 1);
}
await vscode.window.showTextDocument(uri.with({ fragment: '' }), { selection });
}

private async openPage(page: RuntimeState['page'], resetConversation = false): Promise<void> {
await this.runner.cancelPreparation();
const runtime = await this.stores.runtime.get();
Expand Down
4 changes: 2 additions & 2 deletions extensions/github1s-ai/src/webview/App.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { html } from 'htm/preact';
import { useEffect, useState } from 'preact/hooks';

import type { ViewMessage, ViewRequest, ViewState } from '@/common/protocol';
import type { ViewEvent, ViewMessage, ViewState } from '@/common/protocol';

import { ChatPage } from './components/ChatPage';
import { HistoryPage } from './components/HistoryPage';
import { SettingsPage } from './components/SettingsPage';

interface AppProps {
post: (request: ViewRequest) => void;
post: (event: ViewEvent) => void;
}

export const App = ({ post }: AppProps) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { Tooltip, TooltipButton } from './Tooltip';
interface AttachmentChipsProps {
attachments: readonly ContextAttachmentDescriptor[];
disabled?: boolean;
onOpen: (source: string) => void;
onRemove?: (id: string) => void;
}

export const AttachmentChips = ({ attachments, disabled = false, onRemove }: AttachmentChipsProps) => {
export const AttachmentChips = ({ attachments, disabled = false, onOpen, onRemove }: AttachmentChipsProps) => {
if (attachments.length === 0) return null;
const readOnly = onRemove === undefined;

Expand All @@ -27,11 +28,11 @@ export const AttachmentChips = ({ attachments, disabled = false, onRemove }: Att
const iconClass = `seti-file-icon seti-file-icon-${icon} seti-file-icon-color-${presentation.color} attachment-icon`;
return html`<span class="attachment-chip" key=${attachment.id}>
<${Tooltip} content=${attachment.source}>
<span class="attachment-name" tabindex="0">
<button class="attachment-name" type="button" onClick=${() => onOpen(attachment.source)}>
<${Icon} name="file" className="attachment-file-fallback" />
<span class=${iconClass} aria-hidden="true">${presentation.glyph}</span>
<span class="attachment-label">${attachment.label}</span>
</span>
</button>
<//>
${onRemove
? html`<${TooltipButton}
Expand Down
4 changes: 2 additions & 2 deletions extensions/github1s-ai/src/webview/components/ChatPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { html } from 'htm/preact';
import { useLayoutEffect, useRef } from 'preact/hooks';

import type { ConversationMessage } from '@/common/conversation';
import type { ViewRequest, ViewState } from '@/common/protocol';
import type { ViewEvent, ViewState } from '@/common/protocol';
import { QUICK_ACTIONS } from '@/common/quick-actions';

import { Composer } from './Composer';
Expand All @@ -11,7 +11,7 @@ import { MessageView } from './MessageView';

interface ChatContextProps {
state: ViewState;
post: (request: ViewRequest) => void;
post: (event: ViewEvent) => void;
}

interface ChatPageProps extends ChatContextProps {
Expand Down
1 change: 1 addition & 0 deletions extensions/github1s-ai/src/webview/components/Composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const Composer = ({ state, busy, active, post }: ComposerProps) => {
<${AttachmentChips}
attachments=${chat.pendingAttachments ?? []}
disabled=${preparing}
onOpen=${(source: string) => post({ type: 'app.openFile', source })}
onRemove=${(id: string) => post({ type: 'chat.removeContextAttachment', id })}
/>
<textarea
Expand Down
13 changes: 8 additions & 5 deletions extensions/github1s-ai/src/webview/components/MessageView.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { html } from 'htm/preact';

import type { ConversationMessage } from '@/common/conversation';
import type { ViewEvent } from '@/common/protocol';

import type { PostMarkdownHighlightRequest } from '../helpers/highlighting';
import { messageStatusLabel } from '../helpers/presentation';
import { AssistantActivity, isAssistantActivityPart, type AssistantActivityPart } from './AssistantActivity';
import { AttachmentChips } from './AttachmentChips';
Expand All @@ -15,7 +15,7 @@ type AssistantSegment =

interface MessageViewProps {
message: ConversationMessage;
post: PostMarkdownHighlightRequest;
post: (event: ViewEvent) => void;
}

export const MessageView = ({ message, post }: MessageViewProps) => {
Expand All @@ -28,7 +28,7 @@ export const MessageView = ({ message, post }: MessageViewProps) => {

return html`<article class=${`message message-${message.role}`}>
${message.role === 'user'
? html`<${UserRequest} message=${message} />`
? html`<${UserRequest} message=${message} post=${post} />`
: html`<div class="assistant-response">
${segments.map((segment) =>
segment.type === 'text'
Expand Down Expand Up @@ -86,13 +86,16 @@ const assistantSegments = (message: ConversationMessage): AssistantSegment[] =>
return segments;
};

const UserRequest = ({ message }: { message: ConversationMessage }) => {
const UserRequest = ({ message, post }: MessageViewProps) => {
const text = message.parts.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('');
const attachments = message.parts.flatMap((part) => (part.type === 'data-attachments' ? part.data : []));
const recentFiles = message.parts.flatMap((part) => (part.type === 'data-recentFiles' ? part.data : []));
const request = html`<div class="user-request" tabindex=${recentFiles.length > 0 ? 0 : undefined}>
<div class="user-content">${text}</div>
<${AttachmentChips} attachments=${attachments} />
<${AttachmentChips}
attachments=${attachments}
onOpen=${(source: string) => post({ type: 'app.openFile', source })}
/>
</div>`;
return recentFiles.length > 0 ? html`<${RecentFilesTooltip} files=${recentFiles}>${request}<//>` : request;
};
4 changes: 2 additions & 2 deletions extensions/github1s-ai/src/webview/helpers/highlighting.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isSyntaxHighlightingData, type SyntaxHighlightingData } from '@/common/highlighting';
import { MAX_SYNTAX_HIGHLIGHT_SOURCE_LENGTH, type MarkdownHighlightRequest } from '@/common/protocol';
import { MAX_SYNTAX_HIGHLIGHT_SOURCE_LENGTH, type ViewEvent } from '@/common/protocol';

export type PostMarkdownHighlightRequest = (request: MarkdownHighlightRequest) => void;
export type PostMarkdownHighlightRequest = (request: ViewEvent<'markdown.highlight'>) => void;

interface CodeBlockRegistration {
element: HTMLElement;
Expand Down
4 changes: 2 additions & 2 deletions extensions/github1s-ai/src/webview/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { html, render } from 'htm/preact';

import type { ViewMessage, ViewRequest } from '@/common/protocol';
import type { ViewEvent, ViewMessage } from '@/common/protocol';

import { App } from './App';
import { setiFileIconPresentation } from './helpers/attachments';
Expand All @@ -10,7 +10,7 @@ const root = document.getElementById('app');

if (!root) throw new Error('Missing webview app root.');

const post = (request: ViewRequest): void => vscode.postMessage(request);
const post = (event: ViewEvent): void => vscode.postMessage(event);

window.addEventListener('message', ({ data }: MessageEvent<unknown>) => {
if (!data || typeof data !== 'object') return;
Expand Down
10 changes: 5 additions & 5 deletions extensions/github1s-ai/test/protocol.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { MAX_SYNTAX_HIGHLIGHT_SOURCE_LENGTH, parseMarkdownHighlightRequest, parseViewEvent } from '@/common/protocol';
import { MAX_SYNTAX_HIGHLIGHT_SOURCE_LENGTH, parseViewEvent } from '@/common/protocol';

test('view events accept chat commands and context descriptors', () => {
for (const event of [
Expand Down Expand Up @@ -43,8 +43,8 @@ test('highlight requests enforce the source length limit and required fields', (
languageId: 'typescript',
source: 'x'.repeat(MAX_SYNTAX_HIGHLIGHT_SOURCE_LENGTH),
};
assert.deepEqual(parseMarkdownHighlightRequest(request), request);
assert.equal(parseMarkdownHighlightRequest({ ...request, source: `${request.source}x` }), undefined);
assert.equal(parseMarkdownHighlightRequest({ ...request, requestId: '' }), undefined);
assert.equal(parseMarkdownHighlightRequest({ ...request, extra: true }), undefined);
assert.deepEqual(parseViewEvent(request), request);
assert.equal(parseViewEvent({ ...request, source: `${request.source}x` }), undefined);
assert.equal(parseViewEvent({ ...request, requestId: '' }), undefined);
assert.equal(parseViewEvent({ ...request, extra: true }), undefined);
});
Loading