Skip to content
Open
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
235 changes: 186 additions & 49 deletions src/browser/components/ChatInput/index.tsx

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions src/browser/components/ChatInput/suggestionReplacement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface SuggestionMatchRange {
startIndex: number;
endIndex: number;
}

export function applySuggestionReplacement(props: {
input: string;
match: SuggestionMatchRange;
replacement: string;
addTrailingSpace: boolean;
}): { nextInput: string; nextCursor: number } {
const trailingSpace = props.addTrailingSpace ? " " : "";

const nextInput =
props.input.slice(0, props.match.startIndex) +
props.replacement +
trailingSpace +
props.input.slice(props.match.endIndex);

const nextCursor = props.match.startIndex + props.replacement.length + trailingSpace.length;

return { nextInput, nextCursor };
}
36 changes: 35 additions & 1 deletion src/browser/components/ChatInput/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import type { APIClient } from "@/browser/contexts/API";
import type { AgentSkillDescriptor } from "@/common/types/agentSkill";
import type { SendMessageError } from "@/common/types/errors";
import type { ParsedRuntime } from "@/common/types/runtime";
import { buildAgentSkillMetadata, type MuxFrontendMetadata } from "@/common/types/message";
import {
buildAgentSkillMetadata,
buildAgentSkillSetMetadata,
type MuxFrontendMetadata,
} from "@/common/types/message";
import type { FilePart } from "@/common/orpc/types";
import type { ChatAttachment } from "../ChatAttachments";
import type { Review } from "@/common/types/review";
Expand Down Expand Up @@ -59,6 +63,36 @@ export function buildSkillInvocationMetadata(
});
}

export function buildHashSkillInvocationMetadata(
rawCommand: string,
descriptors: AgentSkillDescriptor[],
mentions: Array<{ name: string }>
): MuxFrontendMetadata | undefined {
const descriptorByName = new Map(descriptors.map((d) => [d.name.toLowerCase(), d] as const));

const skills: Array<{ skillName: string; scope: AgentSkillDescriptor["scope"] }> = [];
const seen = new Set<string>();

for (const mention of mentions) {
const key = mention.name.toLowerCase();
if (seen.has(key)) {
continue;
}
const descriptor = descriptorByName.get(key);
if (!descriptor) {
continue;
}
seen.add(key);
skills.push({ skillName: descriptor.name, scope: descriptor.scope });
}

if (skills.length === 0) {
return undefined;
}

return buildAgentSkillSetMetadata({ rawCommand, skills });
}

/**
* Format user message text for skill invocation.
* Makes it explicit to the model that a skill was invoked.
Expand Down
178 changes: 84 additions & 94 deletions src/browser/components/SkillIndicator.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from "react";
import { Check } from "lucide-react";
import { Check, Plus } from "lucide-react";
import { cn } from "@/common/lib/utils";
import { SkillIcon } from "@/browser/components/icons/SkillIcon";
import { HoverClickPopover } from "@/browser/components/ui/hover-click-popover";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/ui/tooltip";
import type { LoadedSkill } from "@/browser/utils/messages/StreamingMessageAggregator";
import type { AgentSkillDescriptor, AgentSkillScope } from "@/common/types/agentSkill";

Expand All @@ -11,6 +11,8 @@ interface SkillIndicatorProps {
loadedSkills: LoadedSkill[];
/** All available skills discovered for this project */
availableSkills: AgentSkillDescriptor[];
/** Callback to insert a skill mention (#skill-name) into the chat input */
onInsertSkill?: (skillName: string) => void;
className?: string;
}

Expand All @@ -21,65 +23,10 @@ const SCOPE_CONFIG: Array<{ scope: AgentSkillScope; label: string }> = [
{ scope: "built-in", label: "Built-in" },
];

interface SkillsPopoverContentProps {
loadedSkills: LoadedSkill[];
availableSkills: AgentSkillDescriptor[];
}

const SkillsPopoverContent: React.FC<SkillsPopoverContentProps> = (props) => {
const loadedSkillNames = new Set(props.loadedSkills.map((skill) => skill.name));

const skillsByScope = new Map<AgentSkillScope, AgentSkillDescriptor[]>();
for (const skill of props.availableSkills) {
const existing = skillsByScope.get(skill.scope) ?? [];
existing.push(skill);
skillsByScope.set(skill.scope, existing);
}

return (
<div className="flex flex-col gap-3">
{SCOPE_CONFIG.map(({ scope, label }) => {
const skills = skillsByScope.get(scope);
if (!skills || skills.length === 0) return null;

return (
<div key={scope} className="flex flex-col gap-1.5">
<div className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase">
{label} skills
</div>
{skills.map((skill) => {
const isLoaded = loadedSkillNames.has(skill.name);
return (
<div key={skill.name} className="flex items-start gap-2">
<div className="bg-muted-foreground/30 mt-1.5 h-1 w-1 shrink-0 rounded-full" />
<div className="flex flex-col">
<span
className={cn(
"text-xs font-medium",
isLoaded ? "text-foreground" : "text-muted-foreground"
)}
>
{skill.name}
{isLoaded && <Check className="text-success ml-1 inline h-3 w-3" />}
</span>
<span className="text-muted-foreground text-[11px] leading-snug">
{skill.description}
</span>
</div>
</div>
);
})}
</div>
);
})}
</div>
);
};

/**
* Indicator showing loaded and available skills in a workspace.
* Displays in the WorkspaceHeader to the right of the notification bell.
* Hover to preview skills organized by scope (Project, Global, Built-in); click to pin the list open.
* Hover to see skills organized by scope (Project, Global, Built-in).
*/
export const SkillIndicator: React.FC<SkillIndicatorProps> = (props) => {
const loadedCount = props.loadedSkills.length;
Expand All @@ -90,40 +37,30 @@ export const SkillIndicator: React.FC<SkillIndicatorProps> = (props) => {
return null;
}

// Hover previews skills; click pins the list open to match the context indicator behavior.
// Build set of loaded skill names for quick lookup
const loadedSkillNames = new Set(props.loadedSkills.map((s) => s.name));

// Group skills by scope
const skillsByScope = new Map<AgentSkillScope, AgentSkillDescriptor[]>();
for (const skill of props.availableSkills) {
const existing = skillsByScope.get(skill.scope) ?? [];
existing.push(skill);
skillsByScope.set(skill.scope, existing);
}

return (
<HoverClickPopover
content={
<SkillsPopoverContent
loadedSkills={props.loadedSkills}
availableSkills={props.availableSkills}
/>
}
side="bottom"
align="end"
sideOffset={8}
contentClassName={cn(
"bg-modal-bg text-foreground z-[9999] rounded px-[10px] py-[6px]",
"text-[11px] font-normal font-sans text-left",
"border border-separator-light shadow-[0_2px_8px_rgba(0,0,0,0.4)]",
"animate-in fade-in-0 zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"max-w-[280px] w-auto min-w-0"
)}
>
<button
type="button"
className={cn(
"relative flex h-6 w-6 shrink-0 items-center justify-center rounded",
"text-muted hover:bg-sidebar-hover hover:text-foreground",
props.className
)}
aria-label={`${loadedCount} of ${totalCount} skill${totalCount === 1 ? "" : "s"} loaded`}
>
<span className="relative flex h-6 w-6 items-center justify-center">
<SkillIcon className="h-4.5 w-4.5" />
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"relative flex h-6 w-6 shrink-0 items-center justify-center rounded",
"text-muted hover:bg-sidebar-hover hover:text-foreground",
props.className
)}
aria-label={`${loadedCount} of ${totalCount} skill${totalCount === 1 ? "" : "s"} loaded`}
>
<SkillIcon className="h-4 w-4" />
<span
className={cn(
"absolute -bottom-1 -right-1 flex h-3.5 min-w-3.5 items-center justify-center",
Expand All @@ -133,8 +70,61 @@ export const SkillIndicator: React.FC<SkillIndicatorProps> = (props) => {
>
{loadedCount}
</span>
</span>
</button>
</HoverClickPopover>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" align="end" className="max-w-[280px]">
<div className="flex flex-col gap-3">
{SCOPE_CONFIG.map(({ scope, label }) => {
const skills = skillsByScope.get(scope);
if (!skills || skills.length === 0) return null;

return (
<div key={scope} className="flex flex-col gap-1.5">
<div className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase">
{label} skills
</div>
{skills.map((skill) => {
const isLoaded = loadedSkillNames.has(skill.name);
return (
<div key={skill.name} className="flex items-start gap-2">
<div className="bg-muted-foreground/30 mt-1.5 h-1 w-1 shrink-0 rounded-full" />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-1">
<span
className={cn(
"text-xs font-medium",
isLoaded ? "text-foreground" : "text-muted-foreground"
)}
>
{skill.name}
{isLoaded && <Check className="text-success ml-1 inline h-3 w-3" />}
</span>
{props.onInsertSkill && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
props.onInsertSkill?.(skill.name);
}}
className="text-muted-foreground hover:text-foreground hover:bg-sidebar-hover ml-auto flex h-4 w-4 shrink-0 items-center justify-center rounded"
title={`Insert #${skill.name} into chat`}
>
<Plus className="h-3 w-3" />
</button>
)}
</div>
<span className="text-muted-foreground text-[11px] leading-snug">
{skill.description}
</span>
</div>
</div>
);
})}
</div>
);
})}
</div>
</TooltipContent>
</Tooltip>
);
};
15 changes: 13 additions & 2 deletions src/browser/components/WorkspaceHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from "react";
import { Bell, BellOff, Menu, Pencil, Server } from "lucide-react";
import { CUSTOM_EVENTS } from "@/common/constants/events";
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
import { cn } from "@/common/lib/utils";

import {
Expand Down Expand Up @@ -335,7 +335,18 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
</div>
</PopoverContent>
</Popover>
<SkillIndicator loadedSkills={loadedSkills} availableSkills={availableSkills} />
<SkillIndicator
loadedSkills={loadedSkills}
availableSkills={availableSkills}
onInsertSkill={(skillName) => {
window.dispatchEvent(
createCustomEvent(CUSTOM_EVENTS.UPDATE_CHAT_INPUT, {
text: `#${skillName} `,
mode: "append",
})
);
}}
/>
{editorError && <span className="text-danger-soft text-xs">{editorError}</span>}
<Tooltip>
<TooltipTrigger asChild>
Expand Down
48 changes: 48 additions & 0 deletions src/browser/utils/hashSkillSuggestions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from "bun:test";
import { getHashSkillSuggestions } from "./hashSkillSuggestions";

describe("getHashSkillSuggestions", () => {
const mockSkills = [
{ name: "react-effects", description: "React effects guidance", scope: "project" as const },
{ name: "tests", description: "Testing patterns", scope: "global" as const },
{ name: "init", description: "Initialize agent", scope: "built-in" as const },
];

it("should return suggestions when cursor is after #", () => {
const result = getHashSkillSuggestions("#", 1, { agentSkills: mockSkills });
expect(result.suggestions.length).toBe(3);
expect(result.match).toEqual({ startIndex: 0, endIndex: 1 });
});

it("should filter suggestions based on partial query", () => {
const result = getHashSkillSuggestions("#re", 3, { agentSkills: mockSkills });
expect(result.suggestions.length).toBe(1);
expect(result.suggestions[0].display).toBe("#react-effects");
expect(result.match).toEqual({ startIndex: 0, endIndex: 3 });
});

it("should return empty when cursor is not in a hash skill", () => {
const result = getHashSkillSuggestions("hello world", 5, { agentSkills: mockSkills });
expect(result.suggestions).toEqual([]);
expect(result.match).toBeNull();
});

it("should return empty when no matching skills", () => {
const result = getHashSkillSuggestions("#xyz", 4, { agentSkills: mockSkills });
expect(result.suggestions).toEqual([]);
expect(result.match).toEqual({ startIndex: 0, endIndex: 4 });
});

it("should include scope in description", () => {
const result = getHashSkillSuggestions("#t", 2, { agentSkills: mockSkills });
const testsSuggestion = result.suggestions.find((s) => s.display === "#tests");
expect(testsSuggestion?.description).toContain("(user)"); // "global" shows as "user"
});

it("should handle hash skill in middle of text", () => {
const result = getHashSkillSuggestions("use #te", 7, { agentSkills: mockSkills });
expect(result.suggestions.length).toBe(1);
expect(result.suggestions[0].display).toBe("#tests");
expect(result.match).toEqual({ startIndex: 4, endIndex: 7 });
});
});
Loading
Loading