Skip to content
Closed
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: 1 addition & 1 deletion apps/app/src/components/chat/message-list-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ interface MessageListProviderProps {
export interface DispatchAction {
target: "settings"
action: "open"
section: "commands" | "skills" | "mcps" | "plugins" | "providers"
section: "commands" | "skills" | "mcps" | "plugins" | "providers" | "connect"
}

export function MessageListProvider({
Expand Down
192 changes: 188 additions & 4 deletions apps/app/src/components/chat/task-suggestions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client"

import { useEffect, useMemo, useState } from "react"
import {
DescriptiveButton,
DescriptiveButtonContent,
Expand All @@ -8,9 +9,23 @@ import {
DescriptiveButtonTitle,
} from "@/components/descriptive-button"
import { useMessageList } from "@/components/chat/message-list-provider"
import { createDenClient, readDenSettings } from "@/app/lib/den"
import { t } from "@/i18n"
import { cn } from "@/lib/utils"
import { useOrgRestrictions } from "@/react-app/domains/cloud/desktop-config-provider"
import {
EMPTY_CONNECT_CAPABILITY_INVENTORY,
listAssignedConnectCapabilities,
type ConnectCapabilityInventory,
type ConnectCapabilityReadiness,
} from "@/react-app/domains/session/surface/connect-capability-inventory"
import { encodeConnectSkillToken } from "@/react-app/domains/session/surface/composer/connect-skill-token"
import { BoltIcon, CubeIcon, DocumentChartBarIcon, GlobeAltIcon, SparklesIcon } from "@heroicons/react/24/solid"
import type {
OnboardingPrompt,
OnboardingPromptConnectSkillReference,
OnboardingPromptSkillReference,
} from "@openwork/types/den/desktop-policies"

const CSV_PROMPT =
"Create a sample CSV file with 20 rows of fake customer data (name, email, company, revenue). Then show me a summary of the data."
Expand All @@ -20,16 +35,164 @@ const BROWSER_PROMPT =

const ORGANIZATION_PROMPT_TITLES = ["Organization prompt 1", "Organization prompt 2", "Organization prompt 3"]

export type OrganizationPromptSkillReadiness = ConnectCapabilityReadiness | "checking"
export type OrganizationPromptCardAction = "fill" | "open_connect" | "blocked"

const EMPTY_PROMPT_READINESS: Record<string, OrganizationPromptSkillReadiness> = {}

function promptText(prompt: string | OnboardingPrompt) {
return typeof prompt === "string" ? prompt : prompt.prompt
}

function promptSkill(prompt: string | OnboardingPrompt) {
return typeof prompt === "string" ? undefined : prompt.skill
}

function skillLabel(skill: OnboardingPromptSkillReference) {
return `/${skill.slug}`
}

export function organizationPromptSkillKey(skill: OnboardingPromptSkillReference) {
return skill.source === "local"
? `local:${skill.slug}`
: `connect:${skill.marketplaceId}:${skill.pluginId}:${skill.configObjectId}:${skill.capabilityName}`
}

function connectSkillPath(skill: OnboardingPromptConnectSkillReference) {
return `openwork-connect://${skill.marketplaceId}/${skill.pluginId}/${skill.configObjectId}`
}

export function readinessLabel(readiness: OrganizationPromptSkillReadiness) {
switch (readiness) {
case "ready":
return t("connect.group_ready")
case "needs_signin":
return t("connect.group_needs_signin")
case "needs_admin_setup":
return t("connect.group_needs_admin_setup")
case "checking":
return "Checking skill readiness"
}
}

function promptSkillToken(skill: OnboardingPromptSkillReference) {
if (skill.source === "local") return `[skill ${skill.slug}]`
return encodeConnectSkillToken({
slug: skill.slug,
name: skill.name,
marketplace: skill.marketplaceName,
capability: skill.capabilityName,
})
}

export function selectionPromptForOrganizationPrompt(prompt: string | OnboardingPrompt) {
const text = promptText(prompt)
const skill = promptSkill(prompt)
return skill ? `${promptSkillToken(skill)} ${text}` : text
}

export function resolveOrganizationPromptCardAction(input: {
skill?: OnboardingPromptSkillReference
readiness: OrganizationPromptSkillReadiness
}): OrganizationPromptCardAction {
if (!input.skill) return "fill"
if (input.readiness === "ready") return "fill"
if (input.readiness === "needs_signin") return "open_connect"
return "blocked"
}

function readinessRecord(
skills: OnboardingPromptConnectSkillReference[],
readiness: OrganizationPromptSkillReadiness,
) {
const record: Record<string, OrganizationPromptSkillReadiness> = {}
for (const skill of skills) {
record[organizationPromptSkillKey(skill)] = readiness
}
return record
}

export function resolveConnectPromptSkillReadiness(input: {
skill: OnboardingPromptConnectSkillReference
inventory: ConnectCapabilityInventory
}): ConnectCapabilityReadiness {
const path = connectSkillPath(input.skill)
const match = input.inventory.skills.find((skill) =>
skill.connectCapabilityName === input.skill.capabilityName || skill.path === path
)
return match?.connectReadiness ?? "needs_admin_setup"
}

export function useOrganizationPromptSkillReadiness(prompts: OnboardingPrompt[] | undefined) {
const connectSkills = useMemo(() => prompts?.flatMap((prompt) =>
prompt.skill?.source === "connect" ? [prompt.skill] : []
) ?? [], [prompts])
const signature = connectSkills.map(organizationPromptSkillKey).join("\n")

const [readinessByKey, setReadinessByKey] = useState<Record<string, OrganizationPromptSkillReadiness>>(EMPTY_PROMPT_READINESS)

useEffect(() => {
if (!connectSkills.length) {
setReadinessByKey(EMPTY_PROMPT_READINESS)
return
}

let cancelled = false
setReadinessByKey(readinessRecord(connectSkills, "checking"))

const loadReadiness = async () => {
const settings = readDenSettings()
const token = settings.authToken?.trim() ?? ""
const organizationId = settings.activeOrgId?.trim() ?? ""
if (!token || !organizationId) {
if (!cancelled) setReadinessByKey(readinessRecord(connectSkills, "needs_signin"))
return
}

const client = createDenClient({ baseUrl: settings.baseUrl, token })
let inventory = EMPTY_CONNECT_CAPABILITY_INVENTORY
try {
inventory = await listAssignedConnectCapabilities({ client, organizationId })
} catch {
if (!cancelled) setReadinessByKey(readinessRecord(connectSkills, "needs_admin_setup"))
return
}

const next: Record<string, OrganizationPromptSkillReadiness> = {}
for (const skill of connectSkills) {
next[organizationPromptSkillKey(skill)] = resolveConnectPromptSkillReadiness({ skill, inventory })
}
if (!cancelled) setReadinessByKey(next)
}

void loadReadiness()
return () => {
cancelled = true
}
}, [signature])

return readinessByKey
}

export function resolveOrganizationPromptCardContent(input: {
prompt: string
prompt: string | OnboardingPrompt
description?: string
index: number
readiness?: OrganizationPromptSkillReadiness
}) {
const prompt = promptText(input.prompt)
const skill = promptSkill(input.prompt)
const readiness = skill ? input.readiness ?? "checking" : "ready"
const title = input.description?.trim()
return {
title: title || ORGANIZATION_PROMPT_TITLES[input.index] || "Organization prompt",
description: input.prompt,
selectionPrompt: input.prompt,
description: prompt,
selectionPrompt: selectionPromptForOrganizationPrompt(input.prompt),
skill,
skillLabel: skill ? skillLabel(skill) : undefined,
readiness,
readinessLabel: skill ? readinessLabel(readiness) : undefined,
action: resolveOrganizationPromptCardAction({ skill, readiness }),
}
}

Expand All @@ -42,6 +205,7 @@ export function TaskSuggestions({ className }: TaskSuggestionsProps) {
const orgRestrictions = useOrgRestrictions()
const organizationPrompts = orgRestrictions.onboardingPrompts
const organizationPromptDescriptions = orgRestrictions.onboardingPromptDescriptions
const readinessByKey = useOrganizationPromptSkillReadiness(organizationPrompts)

if (!displaySuggestions) {
return null
Expand Down Expand Up @@ -86,18 +250,38 @@ export function TaskSuggestions({ className }: TaskSuggestionsProps) {

{hasOrganizationPrompts ? (
organizationPrompts.map((prompt, index) => {
const skill = prompt.skill
const card = resolveOrganizationPromptCardContent({
prompt,
description: organizationPromptDescriptions?.[index],
index,
readiness: skill ? readinessByKey[organizationPromptSkillKey(skill)] : undefined,
})
const disabled = card.action === "blocked"
const handleClick = () => {
if (card.action === "open_connect") {
dispatchAction({
target: "settings",
action: "open",
section: "connect",
})
return
}
if (card.action === "fill") setPrompt(card.selectionPrompt)
}
return (
<DescriptiveButton key={`${index}-${prompt}`} orientation="vertical" onClick={() => setPrompt(card.selectionPrompt)}>
<DescriptiveButton key={`${index}-${prompt.prompt}`} orientation="vertical" onClick={handleClick} disabled={disabled}>
<DescriptiveButtonIcon>
<SparklesIcon className="size-6 text-purple-10" aria-hidden />
</DescriptiveButtonIcon>
<DescriptiveButtonContent>
<DescriptiveButtonTitle>{card.title}</DescriptiveButtonTitle>
{card.skillLabel || card.readinessLabel ? (
<span className="flex flex-wrap gap-1 text-[11px] font-medium text-muted-foreground">
{card.skillLabel ? <span>{card.skillLabel}</span> : null}
{card.readinessLabel ? <span>{card.readinessLabel}</span> : null}
</span>
) : null}
<DescriptiveButtonDescription>{card.description}</DescriptiveButtonDescription>
</DescriptiveButtonContent>
</DescriptiveButton>
Expand Down
50 changes: 47 additions & 3 deletions apps/app/src/react-app/domains/session/chat/session-empty-hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,53 @@
import { useState } from "react";
import { Zap } from "lucide-react";

import { resolveOrganizationPromptCardContent } from "@/components/chat/task-suggestions";
import {
organizationPromptSkillKey,
resolveOrganizationPromptCardContent,
useOrganizationPromptSkillReadiness,
type OrganizationPromptCardAction,
} from "@/components/chat/task-suggestions";
import { useOrgRestrictions } from "@/react-app/domains/cloud/desktop-config-provider";
import { NewTaskComposer, type NewTaskComposerContext } from "./new-task-composer";

type HeroSuggestion = {
title: string;
description: string;
prompt: string;
action: OrganizationPromptCardAction;
disabled: boolean;
skillLabel?: string;
readinessLabel?: string;
};

const DEFAULT_SUGGESTIONS: HeroSuggestion[] = [
{
title: "Summarize my week",
description: "Pull highlights from email and calendar.",
prompt: "Summarize my week: pull the highlights from my connected email and calendar and give me a short digest of what happened and what needs my attention.",
action: "fill",
disabled: false,
},
{
title: "Clean up a spreadsheet",
description: "Drop in a CSV and describe the result you want.",
prompt: "Create a sample CSV file with 20 rows of fake customer data (name, email, company, revenue). Then show me a summary of the data.",
action: "fill",
disabled: false,
},
{
title: "Draft a document",
description: "Reports, emails, or briefs from a few bullet points.",
prompt: "Draft a one-page project brief. Ask me for the bullet points you need, then turn them into a clear, well-structured document.",
action: "fill",
disabled: false,
},
{
title: "Automate a web task",
description: "Use the built-in browser for repetitive steps.",
prompt: "Open craigslist.org in the browser and search for couches for sale. Show me the top 5 results with prices.",
action: "fill",
disabled: false,
},
];

Expand All @@ -42,6 +59,7 @@ export type SessionEmptyHeroProps = {
/** Called with the task prompt; the caller creates the session (and workspace if needed). */
onRunTask: (prompt: string) => void;
onOpenProviderAuth?: () => void;
onOpenConnect?: () => void;
/** Workspace-scoped wiring for the full composer (skills, agents, models). */
composer?: NewTaskComposerContext | null;
};
Expand All @@ -57,14 +75,25 @@ export function SessionEmptyHero(props: SessionEmptyHeroProps) {
const orgRestrictions = useOrgRestrictions();

const organizationPrompts = orgRestrictions.onboardingPrompts;
const readinessByKey = useOrganizationPromptSkillReadiness(organizationPrompts);
const suggestions: HeroSuggestion[] = organizationPrompts !== undefined
? organizationPrompts.map((orgPrompt, index) => {
const skill = orgPrompt.skill;
const card = resolveOrganizationPromptCardContent({
prompt: orgPrompt,
description: orgRestrictions.onboardingPromptDescriptions?.[index],
index,
readiness: skill ? readinessByKey[organizationPromptSkillKey(skill)] : undefined,
});
return { title: card.title, description: card.description, prompt: card.selectionPrompt };
return {
action: card.action,
description: card.description,
disabled: card.action === "blocked",
prompt: card.selectionPrompt,
readinessLabel: card.readinessLabel,
skillLabel: card.skillLabel,
title: card.title,
};
})
: DEFAULT_SUGGESTIONS;

Expand All @@ -79,6 +108,14 @@ export function SessionEmptyHero(props: SessionEmptyHeroProps) {
window.dispatchEvent(new Event("openwork:focusPrompt"));
};

const handleSuggestionClick = (suggestion: HeroSuggestion) => {
if (suggestion.action === "open_connect") {
props.onOpenConnect?.();
return;
}
if (suggestion.action === "fill") fillPrompt(suggestion.prompt);
};

return (
<div className="mx-auto w-full max-w-[640px] space-y-6 px-6">
<div className="space-y-1.5 text-center">
Expand Down Expand Up @@ -118,9 +155,16 @@ export function SessionEmptyHero(props: SessionEmptyHeroProps) {
key={suggestion.title}
type="button"
className="rounded-xl border border-border bg-background p-3.5 text-left transition-colors hover:bg-accent"
onClick={() => fillPrompt(suggestion.prompt)}
disabled={suggestion.disabled}
onClick={() => handleSuggestionClick(suggestion)}
>
<div className="truncate text-[13px] font-medium text-foreground">{suggestion.title}</div>
{suggestion.skillLabel || suggestion.readinessLabel ? (
<div className="mt-0.5 flex flex-wrap gap-1 text-[11px] font-medium text-muted-foreground">
{suggestion.skillLabel ? <span>{suggestion.skillLabel}</span> : null}
{suggestion.readinessLabel ? <span>{suggestion.readinessLabel}</span> : null}
</div>
) : null}
<div className="mt-0.5 line-clamp-2 text-[12px] leading-[17px] text-muted-foreground">
{suggestion.description}
</div>
Expand Down
Loading
Loading