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
2 changes: 2 additions & 0 deletions apps/app/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,10 @@ export default {
"composer.image_kind": "Image",
"composer.inserted_links_unsupported": "Inserted links for unsupported files.",
"composer.loading_commands": "Loading commands...",
"composer.mcp_settings_action": "Open MCP settings for {name}: {status}",
"composer.mcps_label": "MCPs",
"composer.source_local": "Local",
"composer.source_organization": "Org",
"composer.no_commands": "No commands found.",
"composer.placeholder": "Describe your task...",
"composer.remote_worker_paste_warning": "This is a remote worker. Sandboxes are remote too. To share files with it, upload them to the Shared folder in the sidebar.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export function ExtensionDetailModal({
}: ExtensionDetailModalProps) {
"use memo";
const resolvedIconSrc = resolveExtensionIconUrl({ iconSrc, iconSlug, serviceUrl: url });
const triggerText = trigger?.trim();

return (
<Dialog
Expand Down Expand Up @@ -426,14 +427,14 @@ export function ExtensionDetailModal({
{/* Skill-specific: trigger + content preview */}
{kind === "ui-control" ? <UiControlConnectionDetails launchCommand={launchCommand} environment={environment} /> : null}

{kind === "skill" && trigger ? (
{kind === "skill" && triggerText ? (
<Card variant="outline" size="sm">
<CardHeader>
<CardTitle>Trigger</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm leading-relaxed text-card-foreground">
{trigger}
{triggerText}
</div>
</CardContent>
</Card>
Expand All @@ -459,7 +460,7 @@ export function ExtensionDetailModal({
})() : null}

{/* What this enables (generic, for non-skills or skills without preview) */}
{showEnablementCard && ((kind !== "skill" && kind !== "ui-control") || (!trigger && !contentPreview && kind !== "ui-control")) ? (
{showEnablementCard && ((kind !== "skill" && kind !== "ui-control") || (!triggerText && !contentPreview && kind !== "ui-control")) ? (
<Card variant="outline" size="sm">
<CardHeader>
<CardTitle>What this enables</CardTitle>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { SkillCard } from "@/app/types";
import { t } from "@/i18n";

export function skillOriginBadgeLabel(skill: Pick<SkillCard, "origin" | "marketplaceName">): string {
if (skill.origin === "openwork-connect") {
const marketplaceName = skill.marketplaceName?.trim();
return marketplaceName ? marketplaceName : t("composer.source_organization");
}

return t("composer.source_local");
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { ModelSelect } from "@/components/model-select";
import { LexicalPromptEditor, type LexicalPromptEditorHandle } from "./editor";
import { listRunningAppsForMention } from "./app-mentions";
import type { ComposerMentionKind } from "./mention-encoding";
import { skillOriginBadgeLabel } from "./capability-origin";
import {
connectSkillSlashCommandOptions,
getSlashCommandQuery,
Expand Down Expand Up @@ -253,6 +254,10 @@ function mcpStatusBadgeClass(status: McpServerStatus) {
}
}

function isActionableMcpStatus(status: McpServerStatus) {
return status === "needs_auth" || status === "needs_client_registration" || status === "failed" || status === "disconnected";
}

function isLocalCapability(origin: SkillCard["origin"] | McpServerEntry["origin"]) {
return origin !== "openwork-connect";
}
Expand Down Expand Up @@ -1504,11 +1509,9 @@ export function ReactSessionComposer(props: ComposerProps) {
<div className="min-w-0 flex-1 truncate text-xs font-semibold text-gray-11">
/{skillMenuSlashCommandName(skill)}
</div>
{isLocalCapability(skill.origin) ? (
<span className="shrink-0 rounded-full bg-gray-3 px-2 py-0.5 text-[10px] font-medium text-gray-11">
{t("composer.source_local")}
</span>
) : null}
<span className="inline-block max-w-[7rem] shrink-0 truncate rounded-full bg-gray-3 px-2 py-0.5 text-[10px] font-medium text-gray-11">
{skillOriginBadgeLabel(skill)}
</span>
</div>
{skill.description ? <div className="truncate text-xs text-gray-10">{skill.description}</div> : null}
{skill.origin === "openwork-connect" ? (
Expand All @@ -1529,35 +1532,57 @@ export function ReactSessionComposer(props: ComposerProps) {
{toolMenuSection === "mcps" ? (
activeMcpItems.length > 0 ? (
<div className="grid gap-1">
{activeMcpItems.map(({ entry, status }) => (
<div key={entry.id ?? entry.name} className="flex items-start gap-3 rounded-[16px] px-3 py-2.5 text-gray-11">
<Plug size={14} className="mt-0.5 shrink-0 text-gray-9" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="truncate text-xs font-semibold text-gray-11">{entry.name}</div>
<div className="flex shrink-0 items-center gap-1">
{isLocalCapability(entry.origin) ? (
<span className="rounded-full bg-gray-3 px-2 py-0.5 text-[10px] font-medium text-gray-11">
{t("composer.source_local")}
{activeMcpItems.map(({ entry, status }) => {
const statusLabel = formatMcpStatusLabel(status);
const content = (
<>
<Plug size={14} className="mt-0.5 shrink-0 text-gray-9" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="truncate text-xs font-semibold text-gray-11">{entry.name}</div>
<div className="flex shrink-0 items-center gap-1">
{isLocalCapability(entry.origin) ? (
<span className="rounded-full bg-gray-3 px-2 py-0.5 text-[10px] font-medium text-gray-11">
{t("composer.source_local")}
</span>
) : null}
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${mcpStatusBadgeClass(status)}`}>
{statusLabel}
</span>
) : null}
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${mcpStatusBadgeClass(status)}`}>
{formatMcpStatusLabel(status)}
</span>
</div>
</div>
<div className="truncate text-xs text-gray-10">
{entry.origin === "openwork-connect"
? [entry.marketplaceName, entry.pluginName].filter(Boolean).join(" · ")
|| entry.config.url
|| "Remote MCP"
: entry.config.type === "remote"
? entry.config.url ?? entry.config.command?.join(" ") ?? "Remote MCP"
: entry.config.command?.join(" ") ?? "Local MCP"}
</div>
</div>
<div className="truncate text-xs text-gray-10">
{entry.origin === "openwork-connect"
? [entry.marketplaceName, entry.pluginName].filter(Boolean).join(" · ")
|| entry.config.url
|| "Remote MCP"
: entry.config.type === "remote"
? entry.config.url ?? entry.config.command?.join(" ") ?? "Remote MCP"
: entry.config.command?.join(" ") ?? "Local MCP"}
</div>
</>
);

return isActionableMcpStatus(status) ? (
<button
key={entry.id ?? entry.name}
type="button"
className="flex w-full items-start gap-3 rounded-[16px] px-3 py-2.5 text-left text-gray-11 transition-colors hover:bg-gray-2/70"
aria-label={t("composer.mcp_settings_action", { name: entry.name, status: statusLabel })}
onClick={() => {
setToolMenuOpen(false);
openToolMenuSettings();
}}
>
{content}
</button>
) : (
<div key={entry.id ?? entry.name} className="flex items-start gap-3 rounded-[16px] px-3 py-2.5 text-gray-11">
{content}
</div>
</div>
))}
);
})}
</div>
) : (
<div className="px-3 py-2 text-xs text-gray-10">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { extractSkillTriggerFromMarkdown } from "@openwork/types/skill-markdown";
import type {
DenOrgMarketplace,
DenOrgMarketplaceResolved,
Expand Down Expand Up @@ -54,8 +55,8 @@ function marketplaceCapabilityName(pluginId: string, configObjectId: string) {
}

function skillTrigger(object: DenPluginConfigObject) {
const path = object.currentRelativePath?.replaceAll("\\", "/");
return path?.match(/(?:^|\/)skills?\/([^/]+)\/SKILL\.md$/i)?.[1];
const source = object.latestVersion?.rawSourceText;
return source ? extractSkillTriggerFromMarkdown(source) : undefined;
}

function remoteMcpSpecs(object: DenPluginConfigObject): RemoteMcpSpec[] {
Expand Down
11 changes: 11 additions & 0 deletions apps/app/tests/composer-capability-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, test } from "bun:test";

import { t } from "../src/i18n";
import { skillOriginBadgeLabel } from "../src/react-app/domains/session/surface/composer/capability-origin";

describe("composer capability origin badges", () => {
test("labels organization skills with their marketplace and local skills as local", () => {
expect(skillOriginBadgeLabel({ origin: "openwork-connect", marketplaceName: "Team tools" })).toBe("Team tools");
expect(skillOriginBadgeLabel({ origin: "local" })).toBe(t("composer.source_local"));
});
});
16 changes: 11 additions & 5 deletions apps/app/tests/connect-capability-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe("assigned OpenWork Connect capability inventory", () => {
updatedAt: null,
latestVersion: {
id: "version_skill",
rawSourceText: "# Escalate ticket",
rawSourceText: "---\ntrigger: When a ticket needs escalation\n---\n# Escalate ticket",
normalizedPayloadJson: null,
sourceRevisionRef: null,
createdAt: null,
Expand Down Expand Up @@ -119,7 +119,7 @@ describe("assigned OpenWork Connect capability inventory", () => {
expect(inventory.skills).toEqual([
expect.objectContaining({
name: "Escalate ticket",
trigger: "escalate-ticket",
trigger: "When a ticket needs escalation",
origin: "openwork-connect",
marketplaceName: "Team tools",
pluginName: "Support kit",
Expand Down Expand Up @@ -218,7 +218,7 @@ describe("assigned OpenWork Connect capability inventory", () => {
expect(inventory.mcpServers).toEqual([]);
});

test("derives the trigger from Windows-style skill paths and omits it when the path is not a SKILL.md", async () => {
test("does not derive the trigger from skill paths when markdown has no trigger", async () => {
const marketplace = {
id: "marketplace_1",
name: "Team tools",
Expand All @@ -241,7 +241,13 @@ describe("assigned OpenWork Connect capability inventory", () => {
currentRelativePath,
status: "active" as const,
updatedAt: null,
latestVersion: null,
latestVersion: {
id: `version_${id}`,
rawSourceText: `# ${title}`,
normalizedPayloadJson: null,
sourceRevisionRef: null,
createdAt: null,
},
},
});

Expand Down Expand Up @@ -274,7 +280,7 @@ describe("assigned OpenWork Connect capability inventory", () => {
});

const byName = Object.fromEntries(inventory.skills.map((skill) => [skill.name, skill]));
expect(byName["Windows skill"]?.trigger).toBe("escalate-ticket");
expect(byName["Windows skill"]?.trigger).toBeUndefined();
expect(byName["Loose skill"]?.trigger).toBeUndefined();
});
});
30 changes: 30 additions & 0 deletions apps/app/tests/skill-markdown-trigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test";

import { extractSkillTriggerFromMarkdown } from "@openwork/types/skill-markdown";

describe("skill markdown trigger extraction", () => {
test("returns frontmatter trigger, quoted trigger, and when values", () => {
expect(extractSkillTriggerFromMarkdown("---\ntrigger: Review high-priority requests\n---\n# Review"))
.toBe("Review high-priority requests");
expect(extractSkillTriggerFromMarkdown("---\ntrigger: 'Review high-priority requests'\n---\n# Review"))
.toBe("Review high-priority requests");
expect(extractSkillTriggerFromMarkdown("---\ntrigger: \"Review high-priority requests\"\n---\n# Review"))
.toBe("Review high-priority requests");
expect(extractSkillTriggerFromMarkdown("---\nwhen: Prepare release notes\n---\n# Release"))
.toBe("Prepare release notes");
});

test("ignores nested keys and unreadable scalar values", () => {
expect(extractSkillTriggerFromMarkdown("---\nmetadata:\n trigger: Nested value\n---\n# Review"))
.toBeUndefined();
expect(extractSkillTriggerFromMarkdown("---\ntrigger: |\n Multi-line trigger\n---\n# Review"))
.toBeUndefined();
});

test("returns the first When to use item and omits absent triggers", () => {
expect(extractSkillTriggerFromMarkdown("# Review\n\n## When to use\n- Triage incoming requests\n- Draft replies"))
.toBe("Triage incoming requests");
expect(extractSkillTriggerFromMarkdown("# Review\n\nUse this skill carefully."))
.toBeUndefined();
});
});
Loading