diff --git a/bun.lock b/bun.lock index 10001bb61958..83e6266be2a1 100644 --- a/bun.lock +++ b/bun.lock @@ -250,7 +250,7 @@ "name": "opencode", "version": "1.1.16", "bin": { - "opencode": "./bin/opencode", + "shopos": "./bin/shopos", }, "dependencies": { "@actions/core": "1.11.1", diff --git a/package.json b/package.json index d134a187a7cd..97140b6cc8e3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.5", + "packageManager": "bun@1.3.6", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", @@ -98,4 +98,4 @@ "patchedDependencies": { "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" } -} +} \ No newline at end of file diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 3f80809727b1..c118af295c6f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -29,6 +29,8 @@ import { Suspense } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) +const Brand = lazy(() => import("@/pages/brand")) +const Commerce = lazy(() => import("@/pages/commerce")) const Loading = () =>
Loading...
declare global { @@ -103,6 +105,22 @@ export function AppInterface(props: { defaultUrl?: string }) { /> } /> + ( + }> + + + )} + /> + ( + }> + + + )} + /> ( diff --git a/packages/app/src/components/agent-activity.tsx b/packages/app/src/components/agent-activity.tsx new file mode 100644 index 000000000000..fd50387aa0db --- /dev/null +++ b/packages/app/src/components/agent-activity.tsx @@ -0,0 +1,58 @@ +import { Show, createSignal, createEffect } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" + +export interface AgentActivityProps { + active: boolean + title?: string + detail?: string + type?: "thinking" | "writing" | "reading" | "success" +} + +export function AgentActivity(props: AgentActivityProps) { + + return ( + +
+
+ + {/* The "Island" */} +
+ + {/* Status Icon / Spinner */} +
+ + + + +
+
+
+
+
+
+ + {/* Content */} +
+
+ + {props.title || "Thinking"} + + + AGENT + +
+ + + {props.detail} + + +
+
+ + {/* Reflection/Glow below */} +
+
+
+
+ ) +} diff --git a/packages/app/src/components/attention-card.tsx b/packages/app/src/components/attention-card.tsx new file mode 100644 index 000000000000..743d3d4a8293 --- /dev/null +++ b/packages/app/src/components/attention-card.tsx @@ -0,0 +1,103 @@ +import { Icon } from "@opencode-ai/ui/icon" +import { Button } from "@opencode-ai/ui/button" + +interface AttentionItemProps { + title: string + impact: string + action: string + urgency: "high" | "medium" +} + +// Helper function to highlight numbers and currency in text +function highlightNumbers(text: string) { + const parts: Array<{ text: string; isNumber: boolean; isNegative?: boolean }> = [] + + // Regex to match numbers, percentages, and currency + const regex = /(\+?\-?\₹?\d+(?:,\d+)*(?:\.\d+)?%?|\d+\s+days?)/g + let lastIndex = 0 + let match + + while ((match = regex.exec(text)) !== null) { + // Add text before the number + if (match.index > lastIndex) { + parts.push({ text: text.slice(lastIndex, match.index), isNumber: false }) + } + + // Determine if negative indicator + const numText = match[0] + const isNegative = numText.includes('-') || + (text.toLowerCase().includes('drop') || + text.toLowerCase().includes('decreased') || + text.toLowerCase().includes('undercutting')) + + parts.push({ text: numText, isNumber: true, isNegative }) + lastIndex = regex.lastIndex + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push({ text: text.slice(lastIndex), isNumber: false }) + } + + return parts.length > 0 ? parts : [{ text, isNumber: false }] +} + +export function AttentionCard(props: AttentionItemProps) { + const iconName = props.urgency === 'high' ? 'alert-triangle' : 'bubble-5' + const impactParts = highlightNumbers(props.impact) + + return ( +
+ + {/* Animated pulse ring for high urgency */} + {props.urgency === 'high' && ( +
+ )} + +
+ {/* Vibrant icon container with animation */} +
+ +
+ +
+

+ {props.title} +

+

+ {impactParts.map((part, i) => + part.isNumber ? ( + + {part.text} + + ) : ( + {part.text} + ) + )} +

+
+
+ +
+ +
+
+ ) +} diff --git a/packages/app/src/components/business-pulse.tsx b/packages/app/src/components/business-pulse.tsx new file mode 100644 index 000000000000..ce0db90f0b18 --- /dev/null +++ b/packages/app/src/components/business-pulse.tsx @@ -0,0 +1,46 @@ +import { Icon } from "@opencode-ai/ui/icon" +import { Show } from "solid-js" + +interface PulseProps { + summary: string + detail: string + trend?: "up" | "down" | "neutral" +} + +export function BusinessPulse(props: PulseProps) { + return ( +
+ {/* Vibrant left accent */} +
+ +
+
+
+ +
+

Daily Brief

+
+ +
+
+ {props.summary} +
+ +
+ {props.detail} + + + 3% + + + + + 12% + + +
+
+
+
+ ) +} diff --git a/packages/app/src/components/decision-item.tsx b/packages/app/src/components/decision-item.tsx new file mode 100644 index 000000000000..6fcc2194322f --- /dev/null +++ b/packages/app/src/components/decision-item.tsx @@ -0,0 +1,36 @@ +import { Icon } from "@opencode-ai/ui/icon" + +interface DecisionProps { + question: string + impact?: string +} + +export function DecisionItem(props: DecisionProps) { + return ( + + ) +} diff --git a/packages/app/src/components/project-card.tsx b/packages/app/src/components/project-card.tsx new file mode 100644 index 000000000000..b43156959234 --- /dev/null +++ b/packages/app/src/components/project-card.tsx @@ -0,0 +1,110 @@ +import { createMemo, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { useGlobalSync } from "@/context/global-sync" +import { DateTime } from "luxon" +import { LocalProject } from "@/context/layout" +import { A, useNavigate } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/util/encode" +import { Button } from "@opencode-ai/ui/button" + +export function ProjectCard(props: { project: LocalProject, homeDir: string }) { + const navigate = useNavigate() + const sync = useGlobalSync() + + // Connect to project store to monitor agent activity + const [store] = sync.child(props.project.worktree) + + // Find any active session + const activeSession = createMemo(() => { + if (!store?.session) return null + return store.session.find(s => { + const status = store.session_status?.[s.id] + return status?.type === 'busy' || status?.type === 'retry' + }) + }) + + const statusDetail = createMemo(() => { + if (!activeSession()) return null + const status = store.session_status?.[activeSession()!.id] + // This is a rough mapping, in a real detailed implementation we'd parse the 'progress' object + return "Processing task..." + }) + + return ( +
{ + navigate(`/${base64Encode(props.project.worktree)}`) + }} + > + {/* Header */} +
+
+

+ {props.project.worktree.split('/').pop()} +

+ + {props.project.worktree.replace(props.homeDir, "~")} + +
+
+ +
+
+ + {/* Content / Stats (Mocked or Real) */} +
+
+

Last Updated

+

+ {DateTime.fromMillis(props.project.time.updated ?? props.project.time.created).toRelative()} +

+
+
+

Sessions

+

+ {store?.session?.length || 0} +

+
+
+ + {/* Footer / Quick Actions */} +
+ + +
+ + {/* Dynamic Agent Overlay (The "Island" Effect) */} + +
+
+
+
+
+
+
+ Agent Active + + {statusDetail()} + +
+
+
+
+
+ ) +} diff --git a/packages/app/src/index.css b/packages/app/src/index.css index e40f0842b157..9842552cd375 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -5,3 +5,19 @@ cursor: default; } } + +/* Agent Activity Animations */ +@keyframes slideUpFade { + from { + opacity: 0; + transform: translateY(20px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.animate-in { + animation: slideUpFade 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} diff --git a/packages/app/src/pages/brand.tsx b/packages/app/src/pages/brand.tsx new file mode 100644 index 000000000000..e1e173835536 --- /dev/null +++ b/packages/app/src/pages/brand.tsx @@ -0,0 +1,274 @@ +import { createSignal, createResource, Show, For, Suspense } from "solid-js" +import { useParams } from "@solidjs/router" +import { useServer } from "@/context/server" +import { Button } from "@opencode-ai/ui/button" +import { Card } from "@opencode-ai/ui/card" +import { Icon } from "@opencode-ai/ui/icon" + +// Types +interface BrandContext { + id: string + name?: string + tone: string[] + primaryColors: string[] + visualPatterns: string[] + assets: any[] + status: "pending" | "processing" | "ready" | "approved" + image?: string +} + +import { AgentActivity } from "@/components/agent-activity" + +export default function BrandPage() { + const params = useParams() + const server = useServer() + const [analyzing, setAnalyzing] = createSignal(false) + const [agentStatus, setAgentStatus] = createSignal<{ title: string, detail: string }>({ title: "Thinking", detail: "Initializing..." }) + + const [brand, { refetch }] = createResource(async () => { + // Using fetch directly as SDK types are not updated + const res = await fetch(`${server.url}/brand`) + return res.json() as Promise + }) + + // Simulating Asset Analysis + const handleUpload = async (e: Event) => { + setAnalyzing(true) + + setAgentStatus({ title: "Ingesting Asset", detail: "Parsing file structure..." }) + await new Promise(r => setTimeout(r, 600)) + + setAgentStatus({ title: "Running OCR", detail: "Extracting text layers and typography..." }) + await new Promise(r => setTimeout(r, 800)) + + setAgentStatus({ title: "Visual Analysis", detail: "Identifying primary colors and logo spacing..." }) + await new Promise(r => setTimeout(r, 800)) + + setAnalyzing(false) + refetch() + } + + const handleApprove = async () => { + setAnalyzing(true) + setAgentStatus({ title: "Locking Context", detail: "Generating immutable brand guidelines..." }) + await fetch(`${server.url}/brand/approve`, { method: "POST" }) + await new Promise(r => setTimeout(r, 1000)) + setAnalyzing(false) + refetch() + } + + const handleInitialize = async () => { + setAnalyzing(true) + setAgentStatus({ title: "Initializing Layer", detail: "Creating brand namespace..." }) + await fetch(`${server.url}/brand`, { + method: "POST", + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: params.dir }) + }) + await new Promise(r => setTimeout(r, 500)) + setAnalyzing(false) + refetch() + } + + return ( +
+ + {/* Header */} +
+
+

Brand Ground Truth

+

Manage the visual identity and guidelines for the AI.

+
+
+ +
+ + Active & Locked +
+
+ +
+ Draft Mode +
+ +
+
+
+ + {/* Main Content */} +
+ + Loading context...
}> + +
+ +
+

No Brand Context Found

+

Initialize the brand layer to start uploading assets and defining guidelines.

+ +
+ }> + + {/* Identity Grid */} +
+ {/* Visual Identity */} + +
+

Visual Identity

+ +
+ +
+
+

Primary Colors

+
+ + {(color) => ( +
+
+ {color} +
+
+ )} +
+ +
+ No colors extracted +
+
+
+
+
+
+ + {/* Tone of Voice */} + +
+

Tone of Voice

+ +
+
+ + {(tone) => ( + + {tone} + + )} + + +
+ No tone attributes analyzed +
+
+
+
+ + {/* Visual Patterns */} + +
+

Visual Rules

+ +
+ +
    + + {(pattern) => ( +
  • +
    + {pattern} +
  • + )} +
    + +
    + No patterns detected +
    +
    +
+
+
+ + {/* Assets Section */} +
+
+
+

Brand Assets

+

Logos, guidelines, and product imagery.

+
+ {brand()?.assets.length || 0} ITEMS +
+ +
+ {/* Upload Dropzone */} +
document.getElementById("file-upload")?.click()}> +
+ +
+ Upload Asset + PNG, PDF, SVG + +
+ + {/* Asset Cards */} + + {(asset) => ( + +
+ + + + + + +
+ + {/* Overlay */} +
+

{asset.filename}

+

{asset.type}

+
+ + {/* Status Indicator (Mock) */} +
+ + + + +
+
+ )} +
+ + {/* Mock Skeleton when analyzing */} + + + Analyzing... + + +
+
+ + {/* Analysis Pipeline Status (Footer) */} +
+
BRAND ID: {brand()?.id}
+
+ VISION: ACTIVE + OCR: ACTIVE + CONTEXT_VERSION: {brand()?.version} +
+
+ + + + +
+ ) +} diff --git a/packages/app/src/pages/commerce.tsx b/packages/app/src/pages/commerce.tsx new file mode 100644 index 000000000000..44a969de04bb --- /dev/null +++ b/packages/app/src/pages/commerce.tsx @@ -0,0 +1,247 @@ +import { createSignal, createResource, Show, For, Suspense } from "solid-js" +import { useServer } from "@/context/server" +import { Button } from "@opencode-ai/ui/button" +import { Card } from "@opencode-ai/ui/card" +import { Icon } from "@opencode-ai/ui/icon" +import { DateTime } from "luxon" + +// Types (Mirroring Backend) +interface Marketplace { + id: string + name: string + currency: string + rules: { commissionPct: number, fixedFee: number } +} + +interface Dataset { + timestamp: number + performance: any[] + economics: any[] + products: any[] +} + +import { AgentActivity } from "@/components/agent-activity" + +export default function CommercePage() { + const server = useServer() + const [activeTab, setActiveTab] = createSignal<"performance" | "economics">("performance") + const [isGenerating, setIsGenerating] = createSignal(false) + const [agentStatus, setAgentStatus] = createSignal<{ title: string, detail: string }>({ title: "Thinking", detail: "Initializing..." }) + + // Fetch Marketplaces + const [marketplaces] = createResource(async () => { + const res = await fetch(`${server.url}/commerce/marketplaces`) + return res.json() as Promise + }) + + // Fetch Data Snapshot + const [data, { refetch }] = createResource(async () => { + const res = await fetch(`${server.url}/commerce/generate`, { method: "POST" }) + return res.json() as Promise + }) + + const refreshData = async () => { + setIsGenerating(true) + + // Agent Simulation Steps + setAgentStatus({ title: "Analyzing Market", detail: "Querying Amazon, Flipkart API..." }) + await new Promise(r => setTimeout(r, 800)) + + setAgentStatus({ title: "Simulating Orders", detail: "Generating 30-day mock transaction history..." }) + await new Promise(r => setTimeout(r, 800)) + + setAgentStatus({ title: "Calculating Economics", detail: "Applying tax, logistics, and commission rules..." }) + await new Promise(r => setTimeout(r, 1000)) + + await refetch() + setIsGenerating(false) + } + + return ( +
+ + + {/* Header */} +
+
+

Commerce Substrate

+

Multi-market data abstraction and simulation layer.

+
+
+
+ + SOURCE: SYNTHETIC +
+ +
+
+ +
+ + {/* Marketplace Connectivity */} +
+

Connected Marketplaces

+
+ + {(market) => ( + +
+
+
+ +
+
+

{market.name}

+
+
+ Online +
+
+
+ + {market.currency} + +
+ +
+
+

Marketplace ID

+

{market.id}

+
+
+

Take Rate

+

{(market.rules.commissionPct * 100).toFixed(1)}%

+
+
+
+ )} +
+
+
+ + {/* Data Views */} +
+
+
+ + +
+
+ Updated: {data() ? DateTime.fromMillis(data()!.timestamp).toFormat("HH:mm:ss") : "--:--:--"} +
+
+ + {/* Table Area */} +
+ Loading data stream...
}> + + + + + + + + + + + + + + + + + {(row) => ( + + + + + + + + + + )} + + +
DateProductMarketplaceUnits SoldRevenueAd SpendSource
{row.date}{row.productID} + + {row.marketplace} + + {row.unitsSold}₹{row.revenue.toLocaleString()}₹{row.adSpend.toLocaleString()} + + {row.dataSource} + +
+
+ + + + + + + + + + + + + + + + {(row) => ( + + + + + + + + + )} + + +
ProductMarketplaceSelling PriceTotal FeesNet ContributionMargin %
{row.productID} + + {row.marketplace} + + ₹{row.sellingPrice.toLocaleString()} + ₹{((row.sellingPrice - row.netContribution)).toLocaleString()} + ₹{row.netContribution.toLocaleString()} + 40 ? 'bg-green-50 text-green-700' : 'bg-yellow-50 text-yellow-700'}`}> + {row.marginPct}% + +
+
+ + +
+ +
+ Showing {data()?.performance.length || 0} records across {marketplaces()?.length || 0} connected marketplaces. +
+ + + + + ) +} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 275113566ad1..bf39704847fc 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1,122 +1,155 @@ +import { BusinessPulse } from "@/components/business-pulse" +import { AttentionCard } from "@/components/attention-card" +import { DecisionItem } from "@/components/decision-item" import { useGlobalSync } from "@/context/global-sync" -import { createMemo, For, Match, Show, Switch } from "solid-js" -import { Button } from "@opencode-ai/ui/button" -import { Logo } from "@opencode-ai/ui/logo" -import { useLayout } from "@/context/layout" -import { useNavigate } from "@solidjs/router" -import { base64Encode } from "@opencode-ai/util/encode" +import { createMemo, Show } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" -import { usePlatform } from "@/context/platform" -import { DateTime } from "luxon" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { DialogSelectDirectory } from "@/components/dialog-select-directory" -import { DialogSelectServer } from "@/components/dialog-select-server" import { useServer } from "@/context/server" +import { useNavigate } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/util/encode" +import { useLayout } from "@/context/layout" export default function Home() { const sync = useGlobalSync() - const layout = useLayout() - const platform = usePlatform() - const dialog = useDialog() - const navigate = useNavigate() const server = useServer() - const homedir = createMemo(() => sync.data.path.home) + const navigate = useNavigate() + const layout = useLayout() - function openProject(directory: string) { - layout.projects.open(directory) - navigate(`/${base64Encode(directory)}`) - } + const handleAsk = (e: KeyboardEvent & { currentTarget: HTMLInputElement }) => { + if (e.key === 'Enter' && e.currentTarget.value.trim()) { + const value = e.currentTarget.value.trim() - async function chooseProject() { - function resolve(result: string | string[] | null) { - if (Array.isArray(result)) { - for (const directory of result) { - openProject(directory) - } - } else if (result) { - openProject(result) - } - } + // Find the most recent project or defalt to first + const recentProject = sync.data.project.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))[0] - if (platform.openDirectoryPickerDialog && server.isLocal()) { - const result = await platform.openDirectoryPickerDialog?.({ - title: "Open project", - multiple: true, - }) - resolve(result) - } else { - dialog.show( - () => , - () => resolve(null), - ) + if (recentProject) { + layout.projects.open(recentProject.worktree) + navigate(`/${base64Encode(recentProject.worktree)}/session/new?prompt=${encodeURIComponent(value)}`) + } else { + // Fallback if no projects exist (unlikely in this persona flow but safe) + alert("Please create a project first to start an agent.") + } } } return ( -
- - - - 0}> -
-
-
Recent projects
- +
+ + {/* 1. BUSINESS PULSE */} + + +
+ + {/* 2. WHAT NEEDS ATTENTION */} +
+
+
+
+
-
    - (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .slice(0, 5)} - > - {(project) => ( - - )} - -
+

Needs Attention

+
+ 3 items +
+ +
+ + + +
+
+ + {/* 3. DECISIONS FOR TODAY */} +
+
+
+ +
+

Decisions You Can Make Today

+
+ +
+
+ +
+
+ +
+
+
- - -
- -
-
No recent projects
-
Get started by opening a local project
+
+
+ + {/* Subtle Divider */} +
+ + {/* 4. SUBTLE ENTRY */} +
+ {/* Subtle background glow on focus */} +
+ +
+
+ +
+ + + +
+ -
-
- - +
+
+ +
+ ShopOS v1.2 + + + + {server.name} Active + +
+
) } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index cffefd5634d9..443ad58fb477 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -894,14 +894,14 @@ export default function Layout(props: ParentProps) { {Math.abs(updated().diffNow().as("seconds")) < 60 ? "Now" : updated() - .toRelative({ - style: "short", - unit: ["days", "hours", "minutes"], - }) - ?.replace(" ago", "") - ?.replace(/ days?/, "d") - ?.replace(" min.", "m") - ?.replace(" hr.", "h")} + .toRelative({ + style: "short", + unit: ["days", "hours", "minutes"], + }) + ?.replace(" ago", "") + ?.replace(/ days?/, "d") + ?.replace(" min.", "m") + ?.replace(" hr.", "h")} @@ -1011,6 +1011,26 @@ export default function Layout(props: ParentProps) {