diff --git a/CHANGELOG.md b/CHANGELOG.md index b54760680..1fe9c9997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [EE] Added image attachments to Ask Sourcebot, letting users attach images to a chat message when the selected model supports image input. [#1375](https://github.com/sourcebot-dev/sourcebot/pull/1375) - Added deployment system resource stats (CPU cores + cgroup quota, host + container memory, disk, load average) to the service ping, so resource issues can be diagnosed more quickly. [#1424](https://github.com/sourcebot-dev/sourcebot/pull/1424) - Added a `robots.txt` that disallows crawlers, with an allowlist for link-preview bots so shared links keep their OpenGraph previews. [#1426](https://github.com/sourcebot-dev/sourcebot/pull/1426) +- [EE] Added Ask Sourcebot skills, letting users create, import, share, sync, and auto-invoke reusable chat instructions across personal and workspace scopes. [#1410](https://github.com/sourcebot-dev/sourcebot/pull/1410) ### Fixed - Send anonymous server-side PostHog events as personless so unauthenticated requests don't inflate person counts. [#1367](https://github.com/sourcebot-dev/sourcebot/pull/1367) diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index f56e19241..cdbc68689 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -576,6 +576,9 @@ }, "externalWebUrl": { "type": "string" + }, + "blobSha": { + "type": "string" } }, "required": [ diff --git a/docs/docs.json b/docs/docs.json index 585b15f2d..71f20c2c5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -66,6 +66,7 @@ "group": "Ask Sourcebot", "root": "docs/features/ask/ask-sourcebot", "pages": [ + "docs/features/ask/skills", "docs/features/ask/connectors", "docs/features/ask/chat-sharing", "docs/features/ask/add-model-providers" diff --git a/docs/docs/features/ask/skills.mdx b/docs/docs/features/ask/skills.mdx new file mode 100644 index 000000000..e974b3e8d --- /dev/null +++ b/docs/docs/features/ask/skills.mdx @@ -0,0 +1,130 @@ +--- +title: Skills +--- + +Skills are reusable context for [Ask Sourcebot](/docs/features/ask/ask-sourcebot). +They can capture organizational knowledge, define a workflow, or give Ask Sourcebot extra context for a class of questions. + + + Skills page overview + + +## Creating a skill + +Go to **Settings -> Skills** and click **Add skill**. + +You can create a skill in three ways: + +- **New skill**: Create a skill directly in Sourcebot. +- **Import from file**: Upload a local `.md` or `.markdown` file and review it before saving. +- **Import from repository**: Choose a markdown file from an indexed repository and keep the skill synced to that file. + +Each skill has four fields: + +- **Name**: A recognizable name for the skill. +- **Command**: The slash command users type to run the skill, such as `/deep-research`. +- **Description**: A short summary of when Ask Sourcebot should use the skill. +- **Instructions**: The markdown context or guidance Ask Sourcebot uses when the skill runs. + + + Create a skill + + +## Writing instructions + +Keep the description specific. Ask Sourcebot uses it to decide when a skill is relevant, and users see it when browsing slash commands. + +Put the useful context or guidance in the instructions. + + + Skill editor + + +## Using skills in chat + +Type `/` at the start of an Ask Sourcebot prompt to browse available skill commands. + +When you select a skill, Ask Sourcebot expands the command into that skill's instructions before answering. If the instructions include file mentions, those files are added to the chat context automatically. + +Ask Sourcebot may also load a matching skill automatically. When this happens, the chat details show the loaded skill name and command. + + + Skill slash command suggestions + + +## Personal and shared skills + +Skills can be personal or shared: + +- **Personal skills** are private to you in the current workspace. +- **Shared skills** are published to the workspace skill catalog. + +You can add or remove shared skills from your own Ask commands from **Settings -> Skills**. Skill authors and organization owners can edit or delete shared skills. + +Organization owners can manage shared skills from **Settings -> Workspace -> Ask Sourcebot**. Owners can also enable **Auto** for a shared skill, which adds it to members' Ask commands by default. + + + Workspace shared skills manager + + +## Importing skills + +### Import from file + +File imports support `.md` and `.markdown` files. If the file has front matter, Sourcebot uses it to prefill the skill name, command, and description. + +Supported front matter fields: + +```md +--- +name: Debug Build Failure +slug: debug-build +description: Investigate a failing CI or build error. +--- +``` + +You can also use `title` instead of `name`, and `command` instead of `slug`. + +After importing a local file, the skill is editable in Sourcebot. + +### Import from repository + +Repository imports let you choose a markdown file from an indexed repository. Sourcebot prefills the create form from the file so you can review it, add a missing description, and save. The resulting skill stays synced to that file. + +Synced skills behave like manually-created skills, with a link back to their source file: + +- Every field stays editable. Local edits are kept until you sync. +- Sourcebot checks whether the indexed source file has changed. +- When an update is available, use **Update from source** to replace the skill's content with the file's. +- When the skill is already up to date, use **Force sync** to restore the content from the source file. +- Either action warns you first if it would overwrite local edits. +- Fields the source file does not provide (for example, a missing `description`) keep their local values when you sync. + + + Synced repository skill + + +## Permissions and synced skills + +Synced skills mirror content from a repository file, so Sourcebot only shows them to users who can access the source repository. + +If repository permission syncing is enabled, sharing a synced skill shows a warning before publishing it to the workspace. Members without access to the source repository will not see the skill. + +Organization owners can still see and manage shared synced skills from workspace settings, even if they do not have access to the source repository. + +## Managing skills + +From **Settings -> Skills**, you can: + +- Search personal and shared skills. +- Create, edit, delete, or share skills. +- Add or remove shared skills from your commands. +- Make a shared skill personal if you manage it. +- Update synced skills from their source file. + +Synced skills show their source repository and file path, plus one of these statuses: + +- **Up to date**: The skill matches the indexed source file. +- **Update available**: The indexed source file has changed. +- **Source file not found**: The file was renamed, deleted, or no longer resolves at the imported revision. +- **Source repo unavailable**: The repository is no longer available or visible. diff --git a/docs/images/skills_create.png b/docs/images/skills_create.png new file mode 100644 index 000000000..b1ebe7765 Binary files /dev/null and b/docs/images/skills_create.png differ diff --git a/docs/images/skills_editor.png b/docs/images/skills_editor.png new file mode 100644 index 000000000..c04776579 Binary files /dev/null and b/docs/images/skills_editor.png differ diff --git a/docs/images/skills_page.png b/docs/images/skills_page.png new file mode 100644 index 000000000..5b875dfad Binary files /dev/null and b/docs/images/skills_page.png differ diff --git a/docs/images/skills_slash_command.png b/docs/images/skills_slash_command.png new file mode 100644 index 000000000..d8a4cfe0d Binary files /dev/null and b/docs/images/skills_slash_command.png differ diff --git a/docs/images/skills_synced_from_repo.png b/docs/images/skills_synced_from_repo.png new file mode 100644 index 000000000..23e71a412 Binary files /dev/null and b/docs/images/skills_synced_from_repo.png differ diff --git a/docs/images/skills_workspace_manager.png b/docs/images/skills_workspace_manager.png new file mode 100644 index 000000000..2d3017450 Binary files /dev/null and b/docs/images/skills_workspace_manager.png differ diff --git a/packages/db/prisma/migrations/20260706000100_add_agent_skills/migration.sql b/packages/db/prisma/migrations/20260706000100_add_agent_skills/migration.sql new file mode 100644 index 000000000..c0b50ec31 --- /dev/null +++ b/packages/db/prisma/migrations/20260706000100_add_agent_skills/migration.sql @@ -0,0 +1,87 @@ +-- CreateEnum +CREATE TYPE "AgentSkillVisibility" AS ENUM ('PERSONAL', 'SHARED'); + +-- CreateTable +CREATE TABLE "AgentSkill" ( + "id" TEXT NOT NULL, + "visibility" "AgentSkillVisibility" NOT NULL, + "scopeId" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "instructions" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "updatedById" TEXT, + "orgId" INTEGER NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "autoEnrolled" BOOLEAN NOT NULL DEFAULT false, + "sourceRepoName" TEXT, + "sourceFilePath" TEXT, + "sourceRevision" TEXT, + "sourceBlobSha" TEXT, + "sourceImportedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AgentSkill_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AgentSkillAdoption" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "orgId" INTEGER NOT NULL, + "agentSkillId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "removedAt" TIMESTAMP(3), + + CONSTRAINT "AgentSkillAdoption_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AgentSkill_createdById_idx" ON "AgentSkill"("createdById"); + +-- CreateIndex +CREATE INDEX "AgentSkill_updatedById_idx" ON "AgentSkill"("updatedById"); + +-- CreateIndex +CREATE INDEX "AgentSkill_orgId_idx" ON "AgentSkill"("orgId"); + +-- CreateIndex +CREATE INDEX "AgentSkill_orgId_visibility_scopeId_idx" ON "AgentSkill"("orgId", "visibility", "scopeId"); + +-- CreateIndex +CREATE INDEX "AgentSkill_shared_catalog_idx" ON "AgentSkill"("orgId", "visibility", "scopeId", "enabled", "updatedAt" DESC, "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "AgentSkill_orgId_visibility_scopeId_slug_key" ON "AgentSkill"("orgId", "visibility", "scopeId", "slug"); + +-- CreateIndex +CREATE INDEX "AgentSkillAdoption_userId_orgId_removedAt_idx" ON "AgentSkillAdoption"("userId", "orgId", "removedAt"); + +-- CreateIndex +CREATE INDEX "AgentSkillAdoption_agentSkillId_idx" ON "AgentSkillAdoption"("agentSkillId"); + +-- CreateIndex +CREATE INDEX "AgentSkillAdoption_orgId_idx" ON "AgentSkillAdoption"("orgId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AgentSkillAdoption_orgId_userId_agentSkillId_key" ON "AgentSkillAdoption"("orgId", "userId", "agentSkillId"); + +-- AddForeignKey +ALTER TABLE "AgentSkill" ADD CONSTRAINT "AgentSkill_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentSkill" ADD CONSTRAINT "AgentSkill_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentSkill" ADD CONSTRAINT "AgentSkill_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentSkillAdoption" ADD CONSTRAINT "AgentSkillAdoption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentSkillAdoption" ADD CONSTRAINT "AgentSkillAdoption_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentSkillAdoption" ADD CONSTRAINT "AgentSkillAdoption_agentSkillId_fkey" FOREIGN KEY ("agentSkillId") REFERENCES "AgentSkill"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f18889a5f..0f72c97d0 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -334,6 +334,8 @@ model Org { repoVisits RepoVisit[] mcpServers McpServer[] + agentSkills AgentSkill[] + agentSkillAdoptions AgentSkillAdoption[] license License? servicePingEvents ServicePingEvent[] @@ -519,6 +521,9 @@ model User { sessionVersion Int @default(0) userMcpServers UserMcpServer[] + createdAgentSkills AgentSkill[] @relation("AgentSkillCreatedBy") + updatedAgentSkills AgentSkill[] @relation("AgentSkillUpdatedBy") + agentSkillAdoptions AgentSkillAdoption[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -741,6 +746,93 @@ model ChatAccess { @@unique([chatId, userId]) } +enum AgentSkillVisibility { + PERSONAL + SHARED +} + +model AgentSkill { + id String @id @default(cuid()) + + // Namespace identity for slash-command uniqueness and lookup. + // This is not a foreign key and should not be used as an authorization check by itself. + // + // Every skill belongs to an org (orgId is always set). + // PERSONAL: scopeId is the user id; the skill is private to that user within the org. + // SHARED: scopeId is String(orgId); the skill is visible to the whole org (opt-in via adoptions). + // + // Future sharing/grants should treat this as the skill's home namespace, not as the ACL. + visibility AgentSkillVisibility + scopeId String + slug String + + name String + description String + instructions String + + // The user who authored the skill. This is audit metadata, separate from + // the namespace that owns the slash-command slug. + createdBy User @relation("AgentSkillCreatedBy", fields: [createdById], references: [id], onDelete: Cascade) + createdById String + + updatedBy User? @relation("AgentSkillUpdatedBy", fields: [updatedById], references: [id], onDelete: SetNull) + updatedById String? + + // The owning org. Both PERSONAL and SHARED skills are scoped to an org, so a + // user's personal skills change when they switch orgs. + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + enabled Boolean @default(true) + autoEnrolled Boolean @default(false) + + // Provenance for skills imported from an indexed repository file. All null for + // skills created manually or imported from a local file. When set, the skill's + // content is a read-only mirror of the source file: edits happen in the repo and + // are pulled in via "Update from source". sourceBlobSha (the git blob OID at + // import) is the comparison key used to detect when the indexed file has changed. + // Sync is personal-only; publishing to shared snapshots the content and drops + // these fields. + sourceRepoName String? + sourceFilePath String? + sourceRevision String? + sourceBlobSha String? + sourceImportedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + adoptions AgentSkillAdoption[] + + @@unique([orgId, visibility, scopeId, slug]) + @@index([createdById]) + @@index([updatedById]) + @@index([orgId]) + @@index([orgId, visibility, scopeId]) + @@index([orgId, visibility, scopeId, enabled, updatedAt(sort: Desc), name], map: "AgentSkill_shared_catalog_idx") +} + +model AgentSkillAdoption { + id String @id @default(cuid()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + agentSkill AgentSkill @relation(fields: [agentSkillId], references: [id], onDelete: Cascade) + agentSkillId String + + createdAt DateTime @default(now()) + removedAt DateTime? + + @@unique([orgId, userId, agentSkillId]) + @@index([userId, orgId, removedAt]) + @@index([agentSkillId]) + @@index([orgId]) +} + // OAuth2 Authorization Server models // @see: https://datatracker.ietf.org/doc/html/rfc6749 diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 245206d9d..7853b4f1d 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,3 +1,62 @@ -import type { User, Account } from ".prisma/client"; +import type { Account, Prisma, User } from ".prisma/client"; export type UserWithAccounts = User & { accounts: Account[] }; -export * from ".prisma/client"; \ No newline at end of file +export * from ".prisma/client"; + +// Personal skills are scoped to the (user, org) pair: a user's personal skills +// change when they switch orgs. scopeId is the userId; orgId binds it to the org. +export const personalAgentSkillScope = (userId: string, orgId: number) => ({ + visibility: "PERSONAL" as const, + scopeId: userId, + orgId, +}); + +// Shared skills are visible to the whole org. scopeId is String(orgId) so every +// shared skill in an org occupies one slug-uniqueness namespace. +export const sharedAgentSkillScope = (orgId: number) => ({ + visibility: "SHARED" as const, + scopeId: String(orgId), + orgId, +}); + +export const personalAgentSkillAuthScope = (userId: string, orgId: number) => ({ + ...personalAgentSkillScope(userId, orgId), + createdById: userId, +}); + +export const sharedAgentSkillAuthScope = (orgId: number) => ({ + ...sharedAgentSkillScope(orgId), +}); + +export const sharedAgentSkillVisibleToUserWhere = (userId: string, orgId: number) => ({ + ...sharedAgentSkillAuthScope(orgId), + enabled: true, + AND: [ + { + OR: [ + { autoEnrolled: true }, + { + adoptions: { + some: { + userId, + orgId, + removedAt: null, + }, + }, + }, + ], + }, + { + NOT: { + adoptions: { + some: { + userId, + orgId, + removedAt: { + not: null, + }, + }, + }, + }, + }, + ], +}) satisfies Prisma.AgentSkillWhereInput; diff --git a/packages/web/package.json b/packages/web/package.json index 64977cd67..0f06c0d58 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -206,6 +206,7 @@ "usehooks-ts": "^3.1.0", "uuid": "^14.0.0", "vscode-icons-js": "^11.6.1", + "yaml": "^2.8.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.24.5" }, @@ -214,6 +215,7 @@ "@eslint/eslintrc": "^3", "@react-email/ui": "6.1.4", "@react-grab/mcp": "^0.1.23", + "@tailwindcss/container-queries": "^0.1.1", "@tanstack/eslint-plugin-query": "^5.74.7", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx index 11960732a..9f86c73d0 100644 --- a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/nav.tsx @@ -22,6 +22,7 @@ import { ServerIcon, Settings2Icon, ShieldIcon, + SparklesIcon, UserIcon, UsersIcon, } from "lucide-react"; @@ -44,6 +45,7 @@ const iconMap = { "user": UserIcon, "mcp": VscMcp, "bot": BotIcon, + "sparkles": SparklesIcon, } satisfies Record; export type NavIconName = keyof typeof iconMap; diff --git a/packages/web/src/app/(app)/@sidebar/components/upgradeBadge.tsx b/packages/web/src/app/(app)/@sidebar/components/upgradeBadge.tsx index 93bf4b2c0..2c8df3027 100644 --- a/packages/web/src/app/(app)/@sidebar/components/upgradeBadge.tsx +++ b/packages/web/src/app/(app)/@sidebar/components/upgradeBadge.tsx @@ -3,7 +3,6 @@ import { Badge } from "@/components/ui/badge" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import { OFFERINGS_DOCS_LINK } from "@/lib/constants" -import * as TooltipPrimitive from "@radix-ui/react-tooltip" import Link from "next/link" export const UpgradeBadge = () => { @@ -16,20 +15,18 @@ export const UpgradeBadge = () => { Pro - - - This feature requires a subscription.{" "} - e.stopPropagation()} - > - Learn more - - - + + This feature requires a subscription.{" "} + e.stopPropagation()} + > + Learn more + + ) } \ No newline at end of file diff --git a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx index 29cedbb71..956f958f7 100644 --- a/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx +++ b/packages/web/src/app/(app)/askgh/[owner]/[repo]/components/landingPage.tsx @@ -13,6 +13,7 @@ import { DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY } from "@/features/chat/const import { getRepoImageSrc } from '@/lib/utils'; import { useMemo, useRef, useState } from "react"; import { useLocalStorage } from "usehooks-ts"; +import type { AskCommandDefinition } from '@/features/chat/commands/types'; interface LandingPageProps { languageModels: LanguageModelInfo[]; @@ -20,6 +21,7 @@ interface LandingPageProps { repoDisplayName?: string; imageUrl?: string | null; repoId: number; + askCommands: AskCommandDefinition[]; isAuthenticated: boolean; maxImageBytes: number; } @@ -30,6 +32,7 @@ export const LandingPage = ({ repoDisplayName, imageUrl, repoId, + askCommands, isAuthenticated, maxImageBytes, }: LandingPageProps) => { @@ -86,6 +89,7 @@ export const LandingPage = ({ isRedirecting={isLoading} selectedSearchScopes={selectedSearchScopes} searchContexts={[]} + askCommands={askCommands} isDisabled={isChatBoxDisabled} isAuthenticated={isAuthenticated} isLoginWallEnabled={true} diff --git a/packages/web/src/app/(app)/askgh/[owner]/[repo]/page.tsx b/packages/web/src/app/(app)/askgh/[owner]/[repo]/page.tsx index caee69b70..8d98f3624 100644 --- a/packages/web/src/app/(app)/askgh/[owner]/[repo]/page.tsx +++ b/packages/web/src/app/(app)/askgh/[owner]/[repo]/page.tsx @@ -13,6 +13,7 @@ import { auth } from "@/auth"; import { hasEntitlement } from "@/lib/entitlements"; import { ChatEntitlementMessage } from "@/features/chat/components/chatEntitlementMessage"; import { env } from "@sourcebot/shared"; +import { listAgentSkillCommandsOrEmpty } from "@/ee/features/chat/skills/skillCommands.server"; interface PageProps { params: Promise<{ owner: string; repo: string }>; @@ -66,6 +67,9 @@ export default async function GitHubRepoPage(props: PageProps) { const repoInfo = await getRepoInfo(repoId) const languageModels = await getConfiguredLanguageModelsInfo() + const askCommands = session?.user + ? await listAgentSkillCommandsOrEmpty() + : []; if (isServiceError(repoInfo)) { throw new ServiceErrorException(repoInfo); @@ -80,6 +84,7 @@ export default async function GitHubRepoPage(props: PageProps) { repoDisplayName={repoInfo.displayName ?? undefined} imageUrl={repoInfo.imageUrl ?? undefined} repoId={repoInfo.id} + askCommands={askCommands} isAuthenticated={!!session?.user} maxImageBytes={env.SOURCEBOT_CHAT_ATTACHMENT_MAX_IMAGE_BYTES} /> diff --git a/packages/web/src/app/(app)/chat/[id]/page.tsx b/packages/web/src/app/(app)/chat/[id]/page.tsx index a16b2a93f..58c14b58c 100644 --- a/packages/web/src/app/(app)/chat/[id]/page.tsx +++ b/packages/web/src/app/(app)/chat/[id]/page.tsx @@ -19,6 +19,7 @@ import { env } from '@sourcebot/shared'; import { hasEntitlement } from '@/lib/entitlements'; import { ChatEntitlementMessage } from '@/features/chat/components/chatEntitlementMessage'; import { captureEvent } from '@/lib/posthog'; +import { listAgentSkillCommandsOrEmpty } from '@/ee/features/chat/skills/skillCommands.server'; interface PageProps { params: Promise<{ @@ -118,6 +119,9 @@ export default async function Page(props: PageProps) { } const { messages, name, visibility, isOwner, isSharedWithUser } = chatInfo; + const askCommands = session?.user && isOwner + ? await listAgentSkillCommandsOrEmpty() + : []; // Track when a non-owner views a shared chat if (!isOwner) { @@ -176,6 +180,7 @@ export default async function Page(props: PageProps) { repos={indexedRepos} searchContexts={searchContexts} messages={messages} + askCommands={askCommands} isOwner={isOwner} isAuthenticated={!!session} isLoginWallEnabled={env.EXPERIMENT_ASK_GH_ENABLED === 'true'} diff --git a/packages/web/src/app/(app)/chat/chatLandingPage.tsx b/packages/web/src/app/(app)/chat/chatLandingPage.tsx index 153a044a2..b78e28e10 100644 --- a/packages/web/src/app/(app)/chat/chatLandingPage.tsx +++ b/packages/web/src/app/(app)/chat/chatLandingPage.tsx @@ -13,12 +13,18 @@ import { env } from "@sourcebot/shared"; import { loadJsonFile } from "@sourcebot/shared"; import { DemoExamples, demoExamplesSchema } from "@/types"; import { auth } from "@/auth"; +import { hasEntitlement } from "@/lib/entitlements"; +import { listAgentSkillCommandsOrEmpty } from "@/ee/features/chat/skills/skillCommands.server"; export async function ChatLandingPage() { const languageModels = await getConfiguredLanguageModelsInfo(); const searchContexts = await getSearchContexts(); const allRepos = await getRepos(); const session = await auth(); + const hasAskEntitlement = await hasEntitlement('ask'); + const askCommands = session?.user && hasAskEntitlement + ? await listAgentSkillCommandsOrEmpty() + : []; const carouselRepos = await getRepos({ where: { @@ -69,6 +75,7 @@ export async function ChatLandingPage() { languageModels={languageModels} repos={allRepos} searchContexts={searchContexts} + askCommands={askCommands} isAuthenticated={!!session} isLoginWallEnabled={env.EXPERIMENT_ASK_GH_ENABLED === 'true'} maxImageBytes={env.SOURCEBOT_CHAT_ATTACHMENT_MAX_IMAGE_BYTES} diff --git a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx index f9349dc92..9e22d12ee 100644 --- a/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx +++ b/packages/web/src/app/(app)/chat/components/landingPageChatBox.tsx @@ -12,11 +12,13 @@ import { useLocalStorage } from "usehooks-ts"; import { DISABLED_MCP_SERVER_IDS_LOCAL_STORAGE_KEY, SELECTED_SEARCH_SCOPES_LOCAL_STORAGE_KEY } from "@/features/chat/constants"; import { SearchModeSelector } from "../../components/searchModeSelector"; import { NotConfiguredErrorBanner } from "@/features/chat/components/notConfiguredErrorBanner"; +import type { AskCommandDefinition } from "@/features/chat/commands/types"; interface LandingPageChatBox { languageModels: LanguageModelInfo[]; repos: RepositoryQuery[]; searchContexts: SearchContextQuery[]; + askCommands: AskCommandDefinition[]; isAuthenticated: boolean; isLoginWallEnabled: boolean; maxImageBytes: number; @@ -26,6 +28,7 @@ export const LandingPageChatBox = ({ languageModels, repos, searchContexts, + askCommands, isAuthenticated, isLoginWallEnabled, maxImageBytes, @@ -49,6 +52,7 @@ export const LandingPageChatBox = ({ isRedirecting={isLoading} selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} + askCommands={askCommands} isDisabled={isChatBoxDisabled} isAuthenticated={isAuthenticated} isLoginWallEnabled={isLoginWallEnabled} diff --git a/packages/web/src/app/(app)/chat/layout.tsx b/packages/web/src/app/(app)/chat/layout.tsx index b4bdcdda5..77cc318c9 100644 --- a/packages/web/src/app/(app)/chat/layout.tsx +++ b/packages/web/src/app/(app)/chat/layout.tsx @@ -1,5 +1,4 @@ import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME } from '@/lib/constants'; -import { NavigationGuardProvider } from 'next-navigation-guard'; import { cookies } from 'next/headers'; import { Suspense } from 'react'; import { McpOAuthStatusToast } from './components/mcpOAuthStatusToast'; @@ -13,14 +12,12 @@ export default async function Layout({ children }: LayoutProps) { const isTutorialDismissed = (await cookies()).get(AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME)?.value === "true"; return ( - // @note: we use a navigation guard here since we don't support resuming streams yet. - // @see: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence#resuming-ongoing-streams - + <> {children} - + ) } diff --git a/packages/web/src/app/(app)/components/searchModeSelector.tsx b/packages/web/src/app/(app)/components/searchModeSelector.tsx index 610f6b5dc..1446354df 100644 --- a/packages/web/src/app/(app)/components/searchModeSelector.tsx +++ b/packages/web/src/app/(app)/components/searchModeSelector.tsx @@ -27,6 +27,7 @@ export const SearchModeSelector = ({ className, }: SearchModeSelectorProps) => { const [focusedSearchMode, setFocusedSearchMode] = useState(searchMode); + const [isOpen, setIsOpen] = useState(false); const router = useRouter(); const onSearchModeChanged = useCallback((value: SearchMode) => { @@ -56,6 +57,8 @@ export const SearchModeSelector = ({
{ + handleNameChange(event.target.value); + }} + placeholder={SKILL_NAME_PLACEHOLDER} + maxLength={80} + /> + + + + )} + /> + ( + + Command +
+ / + + { + handleSlugChange(event.target.value); + }} + onBlur={() => { + field.onBlur(); + handleSlugBlur(); + }} + placeholder={SKILL_COMMAND_PLACEHOLDER} + className="pl-7 font-mono" + maxLength={64} + /> + +
+ +
+ )} + /> + ( + + Description + +