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
1 change: 1 addition & 0 deletions build/lib/stylelint/vscode-known-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,7 @@
"others": [
"--action-widget-close-start-opacity",
"--action-widget-close-start-transform",
"--activity-bar-action-gap",
"--activity-bar-action-height",
"--activity-bar-icon-size",
"--activity-bar-width",
Expand Down
2 changes: 1 addition & 1 deletion build/win32/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion build/win32/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "inno_updater"
version = "0.22.0"
version = "0.23.0"
authors = ["Microsoft <monacotools@microsoft.com>"]
edition = "2024"
build = "build.rs"
Expand Down
Binary file modified build/win32/inno_updater.exe
Binary file not shown.
45 changes: 42 additions & 3 deletions src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,28 @@ function getImageMimeType(uri: URI): string | undefined {
return mimeType?.startsWith('image/') ? mimeType : undefined;
}

/**
* A comparison key for a website URL that ignores origin casing and a trailing
* slash, so an artifact and the browser showing it are recognized as the same page.
*/
function websiteKey(url: string): string | undefined {
const parsed = URL.parse(url);
if (!parsed) {
return undefined;
}
const path = parsed.pathname.length > 1 && parsed.pathname.endsWith('/') ? parsed.pathname.slice(0, -1) : parsed.pathname;
return `${parsed.protocol}//${parsed.host}${path}${parsed.search}${parsed.hash}`;
}

/** Whether a website artifact points at a page one of the listed browsers shows. */
function isShownInBrowser(link: URI | undefined, browserKeys: ReadonlySet<string>): boolean {
if (!link || browserKeys.size === 0) {
return false;
}
const key = websiteKey(link.toString());
return !!key && browserKeys.has(key);
}

function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions): IChatPillEntry | undefined {
if (artifact.kind === SessionArtifactKind.File) {
if (!artifact.uri) {
Expand Down Expand Up @@ -131,14 +153,25 @@ function toEntry(artifact: ISessionArtifact, actions: ISessionArtifactActions):
/**
* Builds the artifact sections shown in the pill: the agent-set artifacts plus
* the previewable files the session wrote outside its workspace, de-duplicated
* with the agent's own entries winning.
* with the agent's own entries winning. Websites the browsers pill already lists
* are left out, so the same page is offered once across the two pills.
*/
export function buildSessionArtifactSections(artifacts: readonly ISessionArtifact[], externalFiles: readonly ISessionFile[], actions: ISessionArtifactActions, imageCarouselEnabled: boolean): readonly IChatPillSection[] {
export function buildSessionArtifactSections(artifacts: readonly ISessionArtifact[], externalFiles: readonly ISessionFile[], actions: ISessionArtifactActions, imageCarouselEnabled: boolean, browserUrls: ReadonlySet<string>): readonly IChatPillSection[] {
const entriesByKind = new Map<SessionArtifactKind, IChatPillEntry[]>();
const images: ISessionArtifactImage[] = [];
const seen = new Set<string>();
const browserKeys = new Set<string>();
for (const url of browserUrls) {
const key = websiteKey(url);
if (key) {
browserKeys.add(key);
}
}

for (const artifact of artifacts) {
if (artifact.kind === SessionArtifactKind.Website && isShownInBrowser(artifact.link, browserKeys)) {
continue;
}
const imageMimeType = artifact.uri ? getImageMimeType(artifact.uri) : undefined;
if (artifact.kind === SessionArtifactKind.File && artifact.uri && imageMimeType) {
if (!seen.has(artifactValueKey(artifact))) {
Expand Down Expand Up @@ -210,6 +243,8 @@ export class SessionArtifacts extends Disposable {

constructor(
session: IObservable<IActiveSession | undefined>,
/** The URLs the browsers pill lists; website artifacts for them are left out. */
private readonly _browserUrls: IObservable<ReadonlySet<string>>,
@IClipboardService private readonly _clipboardService: IClipboardService,
@ICommandService private readonly _commandService: ICommandService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
Expand All @@ -229,6 +264,7 @@ export class SessionArtifacts extends Disposable {
this._readExternalFiles(current, reader),
this._actions(),
imageCarouselEnabled.read(reader),
this._browserUrls.read(reader),
);
});
}
Expand All @@ -239,7 +275,10 @@ export class SessionArtifacts extends Disposable {

private _actions(): ISessionArtifactActions {
return {
openExternal: link => { void this._openerService.open(link, { openExternal: true }); },
// Contributed openers make a link behave the same here as in the response
// markdown it came from, so a localhost page lands in the integrated
// browser rather than the system one.
openExternal: link => { void this._openerService.open(link, { openExternal: true, allowContributedOpeners: true, fromUserGesture: true }); },
openResource: uri => {
if (previewKind(uri)) {
void openChatTurnFile({ uri, kind: previewKind(uri)!, created: false }, this._openerService, this._configurationService);
Expand Down
63 changes: 51 additions & 12 deletions src/vs/sessions/contrib/chat/browser/sessionBrowsersControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { Codicon } from '../../../../base/common/codicons.js';
import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { derived, IObservable, IReader, observableSignal, observableValue } from '../../../../base/common/observable.js';
import { derived, derivedOpts, IObservable, IReader, observableSignal, observableValue } from '../../../../base/common/observable.js';
import { isEqual } from '../../../../base/common/resources.js';
import { localize } from '../../../../nls.js';
import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js';
Expand All @@ -26,11 +26,30 @@ export const sessionBrowsersPillOptions: IChatDropdownPillOptions = {
summaryAriaLabel: count => localize('browsers.show', "Show {0} browsers", count),
};

const NO_URLS: ReadonlySet<string> = new Set();

function urlsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a === b) {
return true;
}
if (a.size !== b.size) {
return false;
}
for (const url of a) {
if (!b.has(url)) {
return false;
}
}
return true;
}

/** Supplies the live browsers of the viewed chat (and its subagents) to its pill. */
export class SessionBrowsersControl extends Disposable {

/** The pill's sections, empty while the user has the pill hidden. */
readonly sections: IObservable<readonly IChatPillSection[]>;
/** The URLs the pill's browsers show, empty while the user has the pill hidden. */
readonly urls: IObservable<ReadonlySet<string>>;
/** Whether there are browsers to show, regardless of the user's visibility choice. */
readonly hasData: IObservable<boolean>;

Expand All @@ -49,25 +68,45 @@ export class SessionBrowsersControl extends Disposable {
) {
super();

const allSections = derived(this, reader => {
// The browsers the pill lists, before the user's visibility choice. Empty while
// the debug overlay supplies its own browsers in their place.
const allBrowsers = derived(this, reader => {
this._browsersChanged.read(reader);
const debugData = this._debugData.read(reader);
const currentSession = session.read(reader);
const currentChat = chat.read(reader);
return !this._debugData.read(reader) && enabled.read(reader) && currentSession && currentChat
// Read the chat list through the reader so browsers registered by a
// subagent show up as soon as that subagent joins the session.
? this._collectBrowsers(this._collectOwnerIds(currentSession, currentChat, reader))
: [];
});

const allSections = derived(this, reader => {
const debugData = this._debugData.read(reader);
const currentChat = chat.read(reader);
const browsers = debugData
? debugData.browsers.map(label => this._entry(label, undefined, currentChat))
: enabled.read(reader) && currentSession && currentChat
// Read the chat list through the reader so browsers registered by a
// subagent show up as soon as that subagent joins the session.
? this._collectBrowsers(this._collectOwnerIds(currentSession, currentChat, reader), currentChat)
: [];
: allBrowsers.read(reader).map(input => this._entry(input.title?.trim() || localize('browsers.browser', "Browser"), input, currentChat));
return browsers.length > 0
? [{ title: localize('browsers.browsers', "Browsers"), entries: browsers }]
: [];
});

// Browser titles and loading states change far more often than the pages
// themselves, so only report a genuinely different set of URLs.
const allUrls = derivedOpts<ReadonlySet<string>>({ owner: this, equalsFn: urlsEqual }, reader => {
const urls = new Set<string>();
for (const input of allBrowsers.read(reader)) {
if (input.url) {
urls.add(input.url);
}
}
return urls;
});

this.hasData = derived(this, reader => getChatPillEntries(allSections.read(reader)).length > 0);
this.sections = derived(this, reader => visible.read(reader) ? allSections.read(reader) : []);
this.urls = derivedOpts<ReadonlySet<string>>({ owner: this, equalsFn: urlsEqual }, reader => visible.read(reader) ? allUrls.read(reader) : NO_URLS);

this._register(this._browserViewService.onDidChangeBrowserViews(() => this._refreshBrowserListeners()));
this._refreshBrowserListeners();
Expand Down Expand Up @@ -96,15 +135,15 @@ export class SessionBrowsersControl extends Disposable {
return ownerIds;
}

private _collectBrowsers(ownerIds: ReadonlySet<string>, chat: IChat | undefined): IChatPillEntry[] {
const entries: IChatPillEntry[] = [];
private _collectBrowsers(ownerIds: ReadonlySet<string>): BrowserEditorInput[] {
const inputs: BrowserEditorInput[] = [];
for (const input of this._browserViewService.getKnownBrowserViews().values()) {
const ownerId = input.model?.owner.type === 'agent' ? input.model.owner.sessionId : undefined;
if (ownerId && ownerIds.has(ownerId)) {
entries.push(this._entry(input.title?.trim() || localize('browsers.browser', "Browser"), input, chat));
inputs.push(input);
}
}
return entries;
return inputs;
}

private _entry(label: string, input: BrowserEditorInput | undefined, chat: IChat | undefined): IChatPillEntry {
Expand Down
11 changes: 7 additions & 4 deletions src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,20 @@ export class SessionChatInputToolbar extends Disposable {
return chat ? computeTurnStats(chat, reader) : EMPTY_DIFF_STATS;
});

const sessionArtifacts = this._register(instantiationService.createInstance(SessionArtifacts, this._session));
const turnStatusPillsEnabled = observeTurnStatusPillsEnabled(this._configurationService);
const visibility = this._register(instantiationService.createInstance(SessionChatPillVisibility));
this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Browsers, reader))));

// The browsers pill already offers the pages it lists, so the artifacts pill
// leaves those websites out.
const sessionArtifacts = this._register(instantiationService.createInstance(SessionArtifacts, this._session, this._browsers.urls));
this._artifactSections = derived(this, reader => {
const debugData = this._debugData.read(reader);
return debugData ? buildDebugArtifactSections(debugData) : sessionArtifacts.sections.read(reader);
});
const sessionCustomizations = this._register(instantiationService.createInstance(SessionCustomizations, this._chat, this._session));
this._customizationSections = sessionCustomizations.sections;

const turnStatusPillsEnabled = observeTurnStatusPillsEnabled(this._configurationService);
const pillsEnabled = derived(reader => this._debugData.read(reader) !== undefined || turnStatusPillsEnabled.read(reader));
const model: IChatTurnPillsModel = {
stats: this._diffStats,
Expand All @@ -168,7 +173,6 @@ export class SessionChatInputToolbar extends Disposable {

const turnPills = this._register(instantiationService.createInstance(ChatTurnPillsProvider, model));
const metadataPills = this._register(instantiationService.createInstance(SessionMetadataPills, this.element, this._session));
const visibility = this._register(instantiationService.createInstance(SessionChatPillVisibility));

// Every pill the session currently has data for, before the user's
// per-kind visibility choices are applied.
Expand All @@ -179,7 +183,6 @@ export class SessionChatInputToolbar extends Disposable {
...turn.filter(pill => pill.action.id !== CHAT_TURN_CHANGES_PILL_ID),
];
});
this._browsers = this._register(instantiationService.createInstance(SessionBrowsersControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Browsers, reader))));
this._backgroundActivities = this._register(instantiationService.createInstance(SessionBackgroundActivitiesControl, this._session, this._chat, turnStatusPillsEnabled, derived(reader => visibility.isVisible(SessionChatPillKind.Subagents, reader))));

// `show-file-icons` lets a resource pill paint its themed file icon.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ suite('Session Artifacts', () => {
{ id: 'resource', kind: SessionArtifactKind.Resource, label: 'Resource', uri: resourceUri },
];

const entries = buildSessionArtifactSections(artifacts, [{ uri: externalFileUri, operation: SessionFileOperation.Created }], actions, true).flatMap(section => section.entries);
const entries = buildSessionArtifactSections(artifacts, [{ uri: externalFileUri, operation: SessionFileOperation.Created }], actions, true, new Set()).flatMap(section => section.entries);
assert.deepStrictEqual(entries.map(entry => {
const content = entry.hover?.content;
return {
Expand All @@ -49,4 +49,26 @@ suite('Session Artifacts', () => {
]);
});

test('leaves out websites the browsers pill already lists', () => {
const pullRequestLink = URI.parse('https://github.com/microsoft/vscode/pull/12');
const artifacts: readonly ISessionArtifact[] = [
{ id: 'docs', kind: SessionArtifactKind.Website, label: 'Docs', link: URI.parse('https://example.com/docs') },
{ id: 'docs-slash', kind: SessionArtifactKind.Website, label: 'Docs Index', link: URI.parse('https://Example.com/docs/') },
{ id: 'deep', kind: SessionArtifactKind.Website, label: 'Deep Link', link: URI.parse('https://example.com/docs/api') },
{ id: 'blog', kind: SessionArtifactKind.Website, label: 'Blog', link: URI.parse('https://other.test/blog') },
{ id: 'pr', kind: SessionArtifactKind.PullRequest, label: 'PR #12', link: pullRequestLink },
];
const labels = (browserUrls: readonly string[]) => buildSessionArtifactSections(artifacts, [], actions, true, new Set(browserUrls))
.flatMap(section => section.entries)
.map(entry => entry.label);

assert.deepStrictEqual({
withBrowsers: labels(['https://example.com/docs', pullRequestLink.toString()]),
withoutBrowsers: labels([]),
}, {
withBrowsers: ['PR #12', 'Deep Link', 'Blog'],
withoutBrowsers: ['PR #12', 'Docs', 'Docs Index', 'Deep Link', 'Blog'],
});
});

});
Original file line number Diff line number Diff line change
Expand Up @@ -260,4 +260,23 @@ suite('SessionBrowsersControl', () => {
fallback: 'browser-0',
});
});

test('publishes the listed browser URLs only while the pill is visible', () => {
const browsers = [
{ title: 'Docs', url: 'https://example.com/docs' },
{ title: 'Subagent Preview', url: 'https://preview.test/', owner: 'subagent' as const },
{ title: 'Other Session', url: 'https://other.test/', owner: 'other' as const },
{ title: 'Blank' },
];

assert.deepStrictEqual({
visible: [...createControl({ browsers }, store).control.urls.get()],
hidden: [...createControl({ browsers, visible: false }, store).control.urls.get()],
disabled: [...createControl({ browsers, enabled: false }, store).control.urls.get()],
}, {
visible: ['https://example.com/docs', 'https://preview.test/'],
hidden: [],
disabled: [],
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { SESSION_CUSTOMIZATIONS_PILL_ID } from '../../browser/sessionCustomizati
suite('SessionChatInputToolbar', () => {
ensureNoDisposablesAreLeakedInTestSuite();

test('maps turn-status, contributed metadata and hosted pill actions onto togglable pill kinds', () => {
test('maps turn-status and hosted pill actions onto togglable pill kinds', () => {
assert.deepStrictEqual([
getSessionChatPillKindForAction(CHAT_TURN_CHANGES_PILL_ID),
getSessionChatPillKindForAction(VIEW_SESSION_CHANGES_COMMAND_ID),
Expand All @@ -25,7 +25,6 @@ suite('SessionChatInputToolbar', () => {
getSessionChatPillKindForAction(OPEN_ISSUE_ACTION_ID),
getSessionChatPillKindForAction(SESSION_BROWSERS_PILL_ID),
getSessionChatPillKindForAction(SESSION_SUBAGENTS_PILL_ID),
getSessionChatPillKindForAction('workbench.agentSessions.action.openFilesView'),
], [
SessionChatPillKind.Changes,
SessionChatPillKind.Changes,
Expand All @@ -35,7 +34,6 @@ suite('SessionChatInputToolbar', () => {
SessionChatPillKind.Issues,
SessionChatPillKind.Browsers,
SessionChatPillKind.Subagents,
undefined,
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/vie
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
import { IsSessionsWindowContext, WorkspaceFolderCountContext } from '../../../../workbench/common/contextkeys.js';
import { SESSIONS_FILES_EMPTY_VIEW_ID, SESSIONS_FILES_VIEW_ID, SessionsExplorerEmptyView, SessionsExplorerView } from './filesView.js';
import './workspaceFolderActions.js';
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
import { SessionHasGitRepositoryContext, SessionHasGitSyncActionRunningContext, IsNewChatSessionContext, IsPhoneLayoutContext, SessionHasWorkspaceContext } from '../../../common/contextkeys.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
Expand Down
Loading
Loading