diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b89a5c92..d0218a4c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [EE] Added DPoP sender-constrained OAuth tokens for MCP clients. [#1395](https://github.com/sourcebot-dev/sourcebot/pull/1395) - [EE] Added text file attachments to Ask Sourcebot, letting users attach text/code/config files to a chat message via the paperclip button, drag-and-drop, or paste, with large pastes auto-converted to attachments. [#1374](https://github.com/sourcebot-dev/sourcebot/pull/1374) - [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) +- [EE] Added Ask Sourcebot skills, letting users create, import, share, sync, and auto-invoke reusable chat instructions across personal and workspace scopes. ### 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 3fe693a3e..0278edc4b 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/packages/db/prisma/migrations/20260624202848_refactor_agent_skills/migration.sql b/packages/db/prisma/migrations/20260624202848_refactor_agent_skills/migration.sql new file mode 100644 index 000000000..8f148f28a --- /dev/null +++ b/packages/db/prisma/migrations/20260624202848_refactor_agent_skills/migration.sql @@ -0,0 +1,83 @@ +-- 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, + "featured" BOOLEAN NOT NULL DEFAULT false, + "autoEnrolled" BOOLEAN NOT NULL DEFAULT false, + "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", "featured" DESC, "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/migrations/20260626120000_remove_featured_from_agent_skill/migration.sql b/packages/db/prisma/migrations/20260626120000_remove_featured_from_agent_skill/migration.sql new file mode 100644 index 000000000..a47038c60 --- /dev/null +++ b/packages/db/prisma/migrations/20260626120000_remove_featured_from_agent_skill/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "AgentSkill_shared_catalog_idx"; + +-- AlterTable +ALTER TABLE "AgentSkill" DROP COLUMN "featured"; + +-- CreateIndex +CREATE INDEX "AgentSkill_shared_catalog_idx" ON "AgentSkill"("orgId", "visibility", "scopeId", "enabled", "updatedAt" DESC, "name"); diff --git a/packages/db/prisma/migrations/20260627223058_add_agent_skill_source_provenance/migration.sql b/packages/db/prisma/migrations/20260627223058_add_agent_skill_source_provenance/migration.sql new file mode 100644 index 000000000..12fb131de --- /dev/null +++ b/packages/db/prisma/migrations/20260627223058_add_agent_skill_source_provenance/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "AgentSkill" ADD COLUMN "sourceBlobSha" TEXT, +ADD COLUMN "sourceFilePath" TEXT, +ADD COLUMN "sourceImportedAt" TIMESTAMP(3), +ADD COLUMN "sourceRepoName" TEXT, +ADD COLUMN "sourceRevision" TEXT; 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 397d0df21..2e31a19b4 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -205,6 +205,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" }, @@ -213,6 +214,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)/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 37e49ce5a..21657f8f7 100644 --- a/packages/web/src/app/(app)/askgh/[owner]/[repo]/page.tsx +++ b/packages/web/src/app/(app)/askgh/[owner]/[repo]/page.tsx @@ -11,6 +11,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/commands.server"; interface PageProps { params: Promise<{ owner: string; repo: string }>; @@ -54,6 +55,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); @@ -68,6 +72,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..7669888eb 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/commands.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..c4030489c 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/commands.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 = ({
+ + {/* Top bar */} +
+
+ + + Back to Ask Sourcebot + + + handleNameChange(event.target.value)} + placeholder={isEditing ? "Untitled skill" : "New skill"} + size={Math.max(form.name.length || (isEditing ? "Untitled skill".length : "New skill".length), 4)} + maxLength={80} + aria-label="Skill name" + className="min-w-0 bg-transparent text-sm font-medium text-foreground outline-none placeholder:text-muted-foreground placeholder:font-normal" + /> + {form.slug && ( + /{form.slug} + )} + +
+
+ + +
+
+ + {/* Body: instructions canvas + details panel */} +
+ {/* Instructions canvas */} +
+
+ + + {isDetailsCollapsed + ? "Focus mode — open details to edit name, command & description" + : "Markdown"} + +
+
+ setForm((current) => ({ ...current, instructions }))} + placeholder={INSTRUCTIONS_PLACEHOLDER} + className="h-full resize-none pb-8 font-mono text-sm leading-relaxed" + /> + + {form.instructions.length.toLocaleString()} / {INSTRUCTIONS_MAX_LENGTH.toLocaleString()} + +
+
+ + {/* Details panel / collapsed rail */} + {isDetailsCollapsed ? ( + + ) : ( +