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
9 changes: 6 additions & 3 deletions app/api/skills/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { chatThreads, draftSkill, saveSkillDraft } from "@/lib/server/procedures
import { notReady } from "@/lib/server/ready";
import { bot, chat, getState } from "@/lib/server/store";
import { sharedOpen } from "@/lib/server/shared";
import { sharedSkillAct, sharedSkillsList } from "@/lib/server/shared-skills";
import { acceptProposal, proposalDiff, rejectProposal } from "@/lib/server/skill-proposals";
import { fromBopsWindow } from "@/lib/server/ui-token";
import {
Expand Down Expand Up @@ -40,7 +41,9 @@ export const dynamic = "force-dynamic";
* (ui-token.ts): a page a bot's Chrome reached this server with mustn't teach a bot steps or approve a script.
* Which labels each bot has (Who has what), a label's name and about line, Suggest labels with AI, and a bot's proposed change
* (skill-proposals.ts: accept, reject, or its diff on what the skill is now) are the owner's too: never while a
* workspace shared with the user is open.
* workspace shared with the user is open. While one is open, every call here is about that workspace's skills alone,
* in orgo-web with the user's own Orgo key, for an Admin there (lib/server/shared-skills.ts), never this Mac's folders
* or the user's own workspace of the id a request names.
*/
const notFromWindow = (request: Request) => (fromBopsWindow(request) ? null : Response.json({ error: "Skills can only be changed in the Bops app." }, { status: 403 }));

Expand All @@ -62,6 +65,7 @@ function fromOf(v: unknown): SkillRef["from"] {
export async function GET(request: Request) {
const unready = notReady() ?? notFromWindow(request);
if (unready) return unready;
if (sharedOpen()) return sharedSkillsList().then((r) => Response.json(r), failed);
const url = new URL(request.url);
const wsId = url.searchParams.get("ws") ?? getState().workspace ?? MAIN_WORKSPACE;
if (!workspaceById(wsId)) return Response.json({ error: "That workspace is gone." }, { status: 404 });
Expand All @@ -75,11 +79,10 @@ export async function POST(request: Request) {
const unready = notReady() ?? notFromWindow(request);
if (unready) return unready;
const b = (await request.json().catch(() => ({}))) as Record<string, unknown>;
if (sharedOpen()) return sharedSkillAct(b).then((r) => Response.json(r), failed);
const wsId = str(b.ws) ?? getState().workspace ?? MAIN_WORKSPACE;
const kind = kindOf(b.kind);
const slug = str(b.slug) ?? "";
const ownerOnly = new Set(["bot-label", "bot-labels", "label-rename", "label-about", "label-suggest", "proposal"]);
if (ownerOnly.has(String(b.action)) && sharedOpen()) return Response.json({ error: "In a shared workspace, only its owner changes this." }, { status: 403 });
try {
switch (b.action) {
case "detail":
Expand Down
13 changes: 13 additions & 0 deletions cloud/test/core-members.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,19 @@ test("a reply a bot drafted for the owner is shown read only: who, where, the wo
});
});

test("a skill a bot saved as a draft comes with its message: which one (kind, slug, name), so an Admin's card opens it in Skills; nothing else of it", () => {
const saved = { kind: "skill", slug: "weekly-recap", name: "Weekly recap", body: "# Weekly recap", botIds: ["max"] };
assert.deepEqual(sharedMessage({ id: "m1", chatId: "bot:max", role: "bot", botId: "max", text: "Saved it.", at: 3, skillDrafts: [saved] }, new Set(["max"])), {
id: "m1",
chatId: "bot:max",
role: "bot",
botId: "max",
text: "Saved it.",
at: 3,
skillDrafts: [{ kind: "skill", slug: "weekly-recap", name: "Weekly recap" }],
});
});

test("a member given a bot sees all of it: the owner's own messages to it, one kept between us, the answers, and the threads that came of it, live too", () => {
const blob = {
workspaces: [{ id: "ws_acme", name: "Acme", memberAccess: { "sam@example.com": { others: "none", bots: { boppy: "read" } } } }],
Expand Down
3 changes: 3 additions & 0 deletions cloud/ws-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ const MESSAGE: Spec = {
// Replies a bot wrote for the owner to send (lib/types.ts ReplyDraft), read only: who and where, the words, and
// whether they went. Never the account, thread or channel ids they go by, nor a Send in progress or why one failed.
drafts: [{ id: true, app: true, toName: true, to: true, subject: true, where: true, text: true, sent: { at: true, text: true } }],
// Skills a bot saved as drafts (Message.skillDrafts): which one, so an Admin's card opens it in Skills to read and
// publish there with their own sign in (components/app/skill-drafts.tsx). Its name only: the skill is Orgo's.
skillDrafts: [{ kind: true, slug: true, name: true }],
};

/** `asking` is a map (asker → teammate): its entries are each a bot id, and with `bots` (someone else's view) both of them a bot they see. */
Expand Down
3 changes: 3 additions & 0 deletions components/app/bops-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { crmOpen } from "@/lib/crm-access";
import { categoriesOpen } from "@/lib/memory-categories";
import { tagsOf } from "@/lib/routine-catalog";
import { CrmView, OpenCrm } from "./crm";
import { OpenSkill } from "./skill-drafts";
import { BoppyLoading, BotMoodsFrom, CallTalking, Mascot, talkingStore } from "./mascot";
import { MemoryView, type MemoryOpen } from "./memory-categories";
import { BROWSER_TAB, BrowserGoTo, browserTabOf, browsesHere, CHAT_TAB, electron, forgetTab, hostOf, isReading, LocalOnly, memoryAgain, NewTab, OpenBotLocal, OpenInBrowser, OpenLink, OpenPreview, TabBar, WebTab, type BrowserGo, type PanelTab, type PreviewOpened } from "./panel-tabs";
Expand Down Expand Up @@ -1034,6 +1035,7 @@ function Bops({ state }: { state: AppState | null }) {
<OpenMadeApp.Provider value={openMadeApp}>
<OpenHostedApp.Provider value={openHostedApp}>
<OpenSettings.Provider value={(at) => openSettings(at)}>
<OpenSkill.Provider value={openSkills}>
<OpenAppTask.Provider value={openAppTask}>
<OpenBotComputer.Provider value={openBotComputer}>
<OpenPreview.Provider value={openPreview}>
Expand Down Expand Up @@ -1472,6 +1474,7 @@ function Bops({ state }: { state: AppState | null }) {
</OpenPreview.Provider>
</OpenBotComputer.Provider>
</OpenAppTask.Provider>
</OpenSkill.Provider>
</OpenSettings.Provider>
</OpenHostedApp.Provider>
</OpenMadeApp.Provider>
Expand Down
3 changes: 3 additions & 0 deletions components/app/chat-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { AskInPlace, ChatAvatar, chatName, clockTime, memberAnswers, needsYou, p
import { ReplyDrafts } from "./reply-drafts";
import { dropCitations, isLocalLink, isTableRow as isRow, isTableRule as isRule, localOnlyLine, plainStep, plainTitle, tableCells as cells } from "@/lib/plain-words";
import { SkillProposals } from "./skill-proposals";
import { SkillDrafts } from "./skill-drafts";

/** Light markdown for bot text: **bold** and [links](url). */
/** How many messages a chat draws at first, and how many more each "Show earlier messages" adds. */
Expand Down Expand Up @@ -2093,6 +2094,8 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO
<ReplyDrafts message={m} state={state} />
{/* Changes it proposed to a skill, each a card with Accept and Reject (read only in a shared workspace). */}
<SkillProposals message={m} state={state} />
{/* Skills it saved as drafts, each a card that opens it in Skills to read and publish. */}
<SkillDrafts message={m} state={state} />
{m.signIn && m.botId && <SignInAsk botId={m.botId} url={m.signIn.url} state={state} />}
{replies}
{m.needed && m.resultOf && <AnsweredLine message={m} session={sessionById(m.resultOf)} state={state} />}
Expand Down
18 changes: 16 additions & 2 deletions components/app/memory-categories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Mascot, Spinner } from "./mascot";
import { IssuesIcon, IssuesPage, isOpen, issuesSoon, useIssues } from "./orbit-issues";
import { SkillsPage, skillsIn } from "./skills";
import type { AppState } from "@/lib/types";
import { sharedSkillsWho } from "@/lib/shared";
import { isFavoriteKind, type FavoriteKind } from "@/lib/favorites";
import { menuPoint, PinIcon, SideMenu, useFavorites, type Favorites } from "./favorites";
import { Fold } from "./fold";
Expand Down Expand Up @@ -747,6 +748,8 @@ function KnowledgeView({
}) {
// A workspace shared with the user: its Knowledge is kept on the owner's Bops, so none is read here (view-only.ts would refuse it).
const owners = state?.shared ? `Knowledge in ${state.shared.name} is kept on the owner's Bops, so it doesn't show here. To find something out, ask a bot in its chat.` : undefined;
// Its skills, for an Admin there: kept in Orgo, so they show and change here with the owner's Mac off (skills.tsx, shared-skills.ts).
const sharedSkills = !!state?.shared && sharedSkillsWho(state.shared);
const { snap, act, failed, retry } = useMemoryCategories(ws, user, !owners);
const { list } = useIssues(ws, user, !owners);
const forgetting = useForget(ws, user, !owners);
Expand Down Expand Up @@ -777,7 +780,8 @@ function KnowledgeView({
.catch(() => ({ error: "Couldn't reach Bops." }) as Answer & { error?: string });
return r.error ?? r;
}, [ws]);
if (owners) return <MemoryPage snap={null} owners={owners} act={act} forgetting={forgetting} category="all" onCategory={onCategory} onClose={onClose} />;
if (owners && sharedSkills && category === "skills" && state) return <SkillsPage state={state} only="skill" focus={focus} focusAt={focusAt} onClose={onClose} />;
if (owners) return <MemoryPage snap={null} owners={owners} onSkills={sharedSkills ? () => onCategory("skills") : undefined} act={act} forgetting={forgetting} category="all" onCategory={onCategory} onClose={onClose} />;
if (category === "issues")
return (
<IssuesPage
Expand Down Expand Up @@ -947,6 +951,7 @@ export function MemoryPage({
failed,
onRetry,
owners,
onSkills,
act,
ask,
forgetting,
Expand All @@ -968,6 +973,8 @@ export function MemoryPage({
onRetry?: () => void;
/** A workspace shared with the user: what the page says in place of its Knowledge, which is the owner's (no kinds, nothing to add or ask). */
owners?: string;
/** With `owners`: its skills show here for this viewer (an Admin there), opened from under the line. */
onSkills?: () => void;
act: MemoryAct;
ask?: (question: string) => Promise<Answer | string>;
/** Forget, with Undo (useForget). */
Expand Down Expand Up @@ -1128,7 +1135,14 @@ export function MemoryPage({
const listView = (
<div ref={list} key={category} className="motion-fade-in flex flex-col gap-5">
{owners ? (
<p className="mx-auto max-w-[400px] py-12 text-center text-[13.5px] leading-5 text-[#6B6B6B]">{owners}</p>
<div className="flex flex-col items-center gap-3 py-12 text-center">
<p className="max-w-[400px] text-[13.5px] leading-5 text-[#6B6B6B]">{owners}</p>
{onSkills && (
<button onClick={onSkills} data-open-skills className={BUTTON}>
Open Skills
</button>
)}
</div>
) : !snap && failed ? (
<div role="alert" className="flex flex-col items-center gap-3 py-12 text-center">
<span className="max-w-[360px] text-[13.5px] leading-5 text-[#6B6B6B]">{failed}</span>
Expand Down
44 changes: 44 additions & 0 deletions components/app/skill-drafts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import { createContext, useContext } from "react";
import { sharedSkillsWho } from "@/lib/shared";
import type { AppState, Message } from "@/lib/types";

/*
* Skills a bot saved as drafts in a chat (Message.skillDrafts, chat.ts create_skill), as cards under its message: which
* skill, and Review and publish, which opens it in Skills, in Knowledge (skills.tsx), where the person reads it and
* publishes it with their own sign in. The card itself never publishes anything. Shown to the owner, and in a workspace
* shared with the user to an Admin there (lib/shared.ts sharedSkillsWho); nobody else sees it.
*/

/** Opens Knowledge, Skills on one skill ("skill:<slug>"): bops-app.tsx gives it, as it gives OpenCrm. */
export const OpenSkill = createContext<((item?: string) => void) | null>(null);

const btn = "shrink-0 rounded-full bg-ink px-3 py-1.5 text-[12.5px] font-medium leading-4 text-white disabled:opacity-50";

export function SkillDrafts({ message: m, state, className = "" }: { message: Message; state: AppState; className?: string }) {
const open = useContext(OpenSkill);
if (!m.skillDrafts?.length) return null;
if (state.shared && !sharedSkillsWho(state.shared)) return null;
// On the owner's own Bops its state says whether it was published since (a shared workspace's skills are Orgo's, read in Skills).
const refs = state.shared ? [] : (state.workspaces ?? []).flatMap((w) => [...(w.skills ?? []), ...(w.apps ?? [])]);
return (
<div data-skill-drafts className={`flex w-[420px] min-w-0 max-w-full flex-col gap-2 ${className}`}>
{m.skillDrafts.map((d) => {
const published = refs.find((r) => r.kind === d.kind && r.slug === d.slug)?.status === "published";
return (
<div key={`${d.kind}:${d.slug}`} data-skill-draft={d.slug} className="flex min-w-0 items-center gap-2 rounded-2xl bg-white px-3.5 py-2.5 shadow-[0_0_0_1px_#E2E2DF]">
<span className="min-w-0 flex-1 truncate text-[12.5px] font-medium leading-4 text-[#3A3A38]" title={d.name}>
{published ? `Published: ${d.name}` : `Saved as a draft: ${d.name}`}
</span>
{open && (
<button type="button" onClick={() => open(`${d.kind}:${d.slug}`)} className={published ? `${btn} bg-[#F2F2F0] text-ink hover:bg-[#EAEAE7]` : btn}>
{published ? "Open in Skills" : "Review and publish"}
</button>
)}
</div>
);
})}
</div>
);
}
Loading
Loading