feat: integrate mobile work chat and Gitea delivery
This commit is contained in:
@@ -2,7 +2,9 @@ export {
|
||||
MobileAssistantChatScreen,
|
||||
MobileExpandedWorkScreen,
|
||||
MobileHomeScreen,
|
||||
MobileWorkChatScreen,
|
||||
MobileWorkListScreen,
|
||||
MobileWorkStackScreen,
|
||||
MobileWorkUnitDetailScreen,
|
||||
} from "./mobile-workspace/index";
|
||||
export type {
|
||||
@@ -12,6 +14,8 @@ export type {
|
||||
MobileSignalView,
|
||||
MobileWorkspaceScreenProps,
|
||||
MobileWorkspaceView,
|
||||
MobileWorkChatScreenProps,
|
||||
MobileWorkStackScreenProps,
|
||||
MobileWorkUnitTone,
|
||||
MobileWorkUnitView,
|
||||
} from "./mobile-workspace/index";
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
export { MobileAssistantChatScreen } from "./mobile-assistant-chat-screen";
|
||||
export { MobileExpandedWorkScreen } from "./mobile-expanded-work-screen";
|
||||
export { MobileHomeScreen } from "./mobile-home-screen";
|
||||
export { MobileSettingsPanel } from "./mobile-settings-panel";
|
||||
export type { MobileSettingsPanelProps } from "./mobile-settings-panel";
|
||||
export { MobileWorkChatScreen } from "./mobile-work-chat-screen";
|
||||
export type { MobileWorkChatScreenProps } from "./mobile-work-chat-screen";
|
||||
export { MobileWorkListScreen } from "./mobile-work-list-screen";
|
||||
export { MobileWorkStackScreen } from "./mobile-work-stack-screen";
|
||||
export type { MobileWorkStackScreenProps } from "./mobile-work-stack-screen";
|
||||
export { MobileWorkUnitDetailScreen } from "./mobile-work-unit-detail-screen";
|
||||
export type {
|
||||
MobileAssistantMessageView,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export interface MobileSettingsPanelProps {
|
||||
readonly modelId: string;
|
||||
readonly modelLabel: string;
|
||||
readonly onClose: () => void;
|
||||
readonly onSignOut?: () => void;
|
||||
readonly userEmail?: string;
|
||||
readonly userName?: string;
|
||||
}
|
||||
|
||||
export const MobileSettingsPanel = ({
|
||||
modelId,
|
||||
modelLabel,
|
||||
onClose,
|
||||
onSignOut,
|
||||
userEmail,
|
||||
userName,
|
||||
}: MobileSettingsPanelProps) => (
|
||||
<section
|
||||
aria-label="Settings"
|
||||
className="absolute inset-0 z-50 flex flex-col bg-[#f7f8f5] text-[#11110f]"
|
||||
>
|
||||
<header className="flex h-20 shrink-0 items-center border-b border-[#e9eae6] bg-white px-4">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-semibold">Settings</h2>
|
||||
<p className="text-[11px] text-[#807b76]">
|
||||
Your account and Zopu runtime.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Close settings"
|
||||
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="rounded-[22px] bg-white p-4">
|
||||
<p className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase">
|
||||
Account
|
||||
</p>
|
||||
<p className="mt-2 text-[15px] font-semibold">
|
||||
{userName ?? "Signed-in user"}
|
||||
</p>
|
||||
{userEmail ? (
|
||||
<p className="mt-0.5 text-[12px] text-[#807b76]">{userEmail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-[22px] bg-white p-4">
|
||||
<label
|
||||
className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase"
|
||||
htmlFor="mobile-stack-model"
|
||||
>
|
||||
Chat model
|
||||
</label>
|
||||
<select
|
||||
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[13px] font-semibold outline-none"
|
||||
disabled
|
||||
id="mobile-stack-model"
|
||||
value={modelId}
|
||||
>
|
||||
<option value={modelId}>{modelLabel}</option>
|
||||
</select>
|
||||
<p className="mt-2 text-[11px] leading-4 text-[#807b76]">
|
||||
The configured OpenRouter model is used for Zopu conversations and
|
||||
project work.
|
||||
</p>
|
||||
</div>
|
||||
{onSignOut ? (
|
||||
<button
|
||||
className="h-12 w-full rounded-[16px] bg-black text-[13px] font-semibold text-white"
|
||||
onClick={onSignOut}
|
||||
type="button"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -0,0 +1,206 @@
|
||||
import { cn } from "@code/ui/lib/utils";
|
||||
import { ArrowLeft, ArrowUp, ExternalLink, Sparkles } from "lucide-react";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "../ai-elements/conversation";
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from "../ai-elements/message";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputBody,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
} from "../ai-elements/prompt-input";
|
||||
import type { MobileWorkspaceView } from "./models";
|
||||
|
||||
export interface MobileWorkChatScreenProps {
|
||||
readonly composerValue: string;
|
||||
readonly data: MobileWorkspaceView;
|
||||
readonly onBack: () => void;
|
||||
readonly onComposerChange: (value: string) => void;
|
||||
readonly onComposerMessageSubmit?: (message: string) => Promise<void>;
|
||||
readonly onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
readonly statusMessage?: string;
|
||||
}
|
||||
|
||||
export const MobileWorkChatScreen = ({
|
||||
composerValue,
|
||||
data,
|
||||
onBack,
|
||||
onComposerChange,
|
||||
onComposerMessageSubmit,
|
||||
onComposerSubmit,
|
||||
statusMessage,
|
||||
}: MobileWorkChatScreenProps) => {
|
||||
const workUnit = data.selectedWorkUnit;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-[100dvh] max-h-[858px] min-h-[700px] w-full max-w-[390px] flex-col overflow-hidden bg-white text-[#0b0b0a]">
|
||||
<header className="flex h-20 shrink-0 items-center border-b border-[#eff0ec] bg-white px-4">
|
||||
<button
|
||||
aria-label="Back to daily focus"
|
||||
className="flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 px-3 text-center">
|
||||
<p className="truncate text-[15px] font-semibold">
|
||||
{workUnit?.title ?? "Work chat"}
|
||||
</p>
|
||||
<p className="text-[10px] text-[#807b76]">
|
||||
{workUnit
|
||||
? `${workUnit.code} · ${workUnit.statusLabel}`
|
||||
: "Loading work unit"}
|
||||
</p>
|
||||
</div>
|
||||
{workUnit?.reviewUrl ? (
|
||||
<a
|
||||
aria-label={`Open pull request ${workUnit.pullRequestNumber ?? ""}`}
|
||||
className="flex size-10 items-center justify-center rounded-full bg-[#c8ff00]"
|
||||
href={workUnit.reviewUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="size-5" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="size-10" />
|
||||
)}
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 bg-[#fefefe]">
|
||||
<Conversation className="h-full">
|
||||
<ConversationContent className="min-h-full gap-5 px-4 pt-5 pb-6">
|
||||
{workUnit ? (
|
||||
<section className="rounded-[22px] bg-[#e8efff] p-4 text-[#14265f]">
|
||||
<div className="flex items-center text-[10px] font-semibold text-[#315dc0]">
|
||||
{workUnit.statusLabel.toUpperCase()}
|
||||
<span className="ml-auto">{workUnit.progress}%</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[17px] leading-5 font-semibold">
|
||||
{workUnit.nextAction}
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-2 text-[12px] leading-4 opacity-70">
|
||||
{workUnit.summary}
|
||||
</p>
|
||||
{workUnit.reviewUrl ? (
|
||||
<a
|
||||
className="mt-4 flex h-10 items-center justify-center gap-2 rounded-[14px] bg-black text-[12px] font-semibold text-white"
|
||||
href={workUnit.reviewUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Review PR #{workUnit.pullRequestNumber}
|
||||
<ExternalLink className="size-4" />
|
||||
</a>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
<Message className="max-w-full gap-0" from="assistant">
|
||||
<MessageContent className="w-full bg-transparent p-0 text-[#11110f] dark:text-[#11110f]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-7 items-center justify-center rounded-[9px] bg-[#c8ff00]">
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<strong className="text-[14px]">Zopu</strong>
|
||||
<span className="rounded-full bg-[#f1f1ef] px-2 py-1 text-[9px] text-[#aaa7a3]">
|
||||
WORK PARTNER
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-[15px] leading-5">
|
||||
This chat is scoped to {workUnit?.title ?? "this work unit"}.
|
||||
Add context, change direction, or ask what happens next.
|
||||
</p>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
{data.assistant.messages.map((message) => (
|
||||
<Message
|
||||
className="max-w-full gap-0"
|
||||
from={message.role}
|
||||
key={message.id}
|
||||
>
|
||||
<MessageContent
|
||||
className={cn(
|
||||
"max-w-full gap-0 overflow-visible p-0",
|
||||
message.role === "user"
|
||||
? "ml-auto w-fit max-w-[86%] rounded-[22px] bg-[#0c0c0b] px-4 py-3 text-[15px] leading-5 text-white group-[.is-user]:rounded-[22px] group-[.is-user]:bg-[#0c0c0b] group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-white"
|
||||
: "w-full bg-transparent text-[#11110f] dark:text-[#11110f]"
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex size-7 items-center justify-center rounded-[9px] bg-[#c8ff00]">
|
||||
<Sparkles className="size-4" />
|
||||
</span>
|
||||
<strong className="text-[14px]">Zopu</strong>
|
||||
</div>
|
||||
<MessageResponse
|
||||
className="mt-3 text-[15px] leading-5 text-[#11110f] dark:text-[#11110f]"
|
||||
isAnimating={data.assistant.isBusy}
|
||||
>
|
||||
{message.text}
|
||||
</MessageResponse>
|
||||
</>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{message.text}</span>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
))}
|
||||
{data.assistant.isBusy ? (
|
||||
<p className="text-[12px] text-[#807b76]">Zopu is working…</p>
|
||||
) : null}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton
|
||||
aria-label="Scroll to latest message"
|
||||
className="bottom-2 size-9 rounded-full border border-[#dededb] bg-white text-black shadow-sm"
|
||||
/>
|
||||
</Conversation>
|
||||
</main>
|
||||
<footer className="relative h-[106px] shrink-0 border-t border-[#eff0ec] bg-white px-3 pt-3">
|
||||
<PromptInput
|
||||
className="w-full [&_[data-slot=input-group]]:min-h-[58px] [&_[data-slot=input-group]]:rounded-full [&_[data-slot=input-group]]:border-0 [&_[data-slot=input-group]]:bg-[#f2f3ef] [&_[data-slot=input-group]]:px-3 [&_[data-slot=input-group]]:py-1.5 [&_[data-slot=input-group]]:shadow-none [&_[data-slot=input-group]]:dark:bg-[#f2f3ef]"
|
||||
onSubmit={async (message) => {
|
||||
if (onComposerMessageSubmit) {
|
||||
await onComposerMessageSubmit(message.text);
|
||||
return;
|
||||
}
|
||||
onComposerChange(message.text);
|
||||
}}
|
||||
>
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
aria-label="Ask about this work unit"
|
||||
className="max-h-20 min-h-10 flex-1 resize-none bg-transparent px-2 py-2.5 text-[14px] leading-5 shadow-none outline-none placeholder:text-[#aaa7a3] dark:bg-transparent"
|
||||
onChange={(event) => onComposerChange(event.currentTarget.value)}
|
||||
placeholder="Ask about this work unit..."
|
||||
value={composerValue}
|
||||
/>
|
||||
<PromptInputSubmit
|
||||
aria-label="Send"
|
||||
className="size-11 shrink-0 rounded-full bg-black text-white hover:bg-black/85 disabled:bg-black disabled:text-white disabled:opacity-100"
|
||||
disabled={!composerValue.trim() || data.assistant.isBusy}
|
||||
size="icon-sm"
|
||||
status={data.assistant.isBusy ? "streaming" : "ready"}
|
||||
>
|
||||
<ArrowUp className="size-5" />
|
||||
</PromptInputSubmit>
|
||||
</PromptInputBody>
|
||||
</PromptInput>
|
||||
<p className="mt-1 text-center text-[9px] text-[#b5b2ae]">
|
||||
{statusMessage ?? "Messages stay with this work unit"}
|
||||
</p>
|
||||
<form className="sr-only" onSubmit={onComposerSubmit} />
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,458 @@
|
||||
import { useSwipeCard } from "@code/ui/hooks/use-swipe-card";
|
||||
import { cn } from "@code/ui/lib/utils";
|
||||
import {
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import type { FormEvent, MouseEvent } from "react";
|
||||
|
||||
import { MobileSettingsPanel } from "./mobile-settings-panel";
|
||||
import type {
|
||||
MobileWorkspaceView,
|
||||
MobileWorkUnitTone,
|
||||
MobileWorkUnitView,
|
||||
} from "./models";
|
||||
|
||||
const toneClass: Readonly<Record<MobileWorkUnitTone, string>> = {
|
||||
blue: "bg-[#e8efff] text-[#14265f]",
|
||||
green: "bg-[#e8f7ef] text-[#105c35]",
|
||||
orange: "bg-[#fff0e8] text-[#8a2e13]",
|
||||
purple: "bg-[#f1e8ff] text-[#3e0b74]",
|
||||
};
|
||||
|
||||
const statusClass: Readonly<Record<MobileWorkUnitTone, string>> = {
|
||||
blue: "bg-[#d5e1ff] text-[#315dc0]",
|
||||
green: "bg-[#d8f1e4] text-[#238050]",
|
||||
orange: "bg-[#ffe0d0] text-[#c53c08]",
|
||||
purple: "bg-[#e6d6ff] text-[#7137ef]",
|
||||
};
|
||||
|
||||
const dateLabel = new Intl.DateTimeFormat("en", {
|
||||
day: "numeric",
|
||||
weekday: "short",
|
||||
})
|
||||
.format(new Date())
|
||||
.toUpperCase();
|
||||
|
||||
interface WorkStackHeaderProps {
|
||||
readonly activeCount: number;
|
||||
readonly onOpenSettings?: () => void;
|
||||
readonly userInitials: string;
|
||||
}
|
||||
|
||||
const WorkStackHeader = ({
|
||||
activeCount,
|
||||
onOpenSettings,
|
||||
userInitials,
|
||||
}: WorkStackHeaderProps) => (
|
||||
<header className="flex h-[96px] shrink-0 items-center bg-white px-4">
|
||||
<div className="flex size-10 items-center justify-center rounded-[14px] bg-[#c8ff00] text-[20px] font-bold text-black">
|
||||
Z
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h1 className="text-[20px] leading-6 font-semibold tracking-[-0.03em] text-[#0b0b0a]">
|
||||
Zopu
|
||||
</h1>
|
||||
<p className="mt-0.5 flex items-center gap-2 text-[13px] leading-4 text-[#7c7772]">
|
||||
<span className="size-1.5 rounded-full bg-[#64ad1f]" />
|
||||
{activeCount} active work units
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Open settings"
|
||||
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef] text-black"
|
||||
onClick={onOpenSettings}
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Open profile"
|
||||
className="ml-2 flex size-10 items-center justify-center rounded-full bg-[#0b0b0a] text-[13px] font-semibold text-white"
|
||||
onClick={onOpenSettings}
|
||||
type="button"
|
||||
>
|
||||
{userInitials}
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
|
||||
interface SwipeWorkCardProps {
|
||||
readonly data: MobileWorkspaceView;
|
||||
readonly onChecked: (workUnitId: string) => void;
|
||||
readonly onOpen: (workUnitId: string) => void;
|
||||
readonly onSendBack: (workUnitId: string) => void;
|
||||
readonly workUnit: MobileWorkUnitView;
|
||||
}
|
||||
|
||||
const SwipeWorkCard = ({
|
||||
data,
|
||||
onChecked,
|
||||
onOpen,
|
||||
onSendBack,
|
||||
workUnit,
|
||||
}: SwipeWorkCardProps) => {
|
||||
const swipeCard = useSwipeCard({
|
||||
itemKey: workUnit.id,
|
||||
onSwipe: (direction) => {
|
||||
if (direction === "right") {
|
||||
onChecked(workUnit.id);
|
||||
return;
|
||||
}
|
||||
onSendBack(workUnit.id);
|
||||
},
|
||||
});
|
||||
const {
|
||||
backLabelRef,
|
||||
cardRef,
|
||||
checkedLabelRef,
|
||||
dragged,
|
||||
pointerHandlers,
|
||||
swipe,
|
||||
transitionHandlers,
|
||||
} = swipeCard;
|
||||
|
||||
const handleOpen = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (dragged()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onOpen(workUnit.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full touch-pan-y select-none">
|
||||
<span
|
||||
className="pointer-events-none absolute top-5 left-5 z-10 rounded-full bg-black px-3 py-1.5 text-[11px] font-semibold text-white opacity-0"
|
||||
ref={backLabelRef}
|
||||
>
|
||||
SEND TO BACK
|
||||
</span>
|
||||
<span
|
||||
className="pointer-events-none absolute top-5 right-5 z-10 rounded-full bg-[#c8ff00] px-3 py-1.5 text-[11px] font-semibold text-black opacity-0"
|
||||
ref={checkedLabelRef}
|
||||
>
|
||||
CHECKED
|
||||
</span>
|
||||
<article
|
||||
aria-label={`${workUnit.title}. Swipe right to check, left to send to back.`}
|
||||
className={cn(
|
||||
"relative h-full overflow-hidden rounded-[30px] p-5 shadow-[0_12px_38px_rgba(43,61,107,0.08)] will-change-transform",
|
||||
toneClass[workUnit.tone]
|
||||
)}
|
||||
ref={cardRef}
|
||||
{...transitionHandlers}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1.5 text-[10px] font-semibold",
|
||||
statusClass[workUnit.tone]
|
||||
)}
|
||||
>
|
||||
{workUnit.statusLabel.toUpperCase()}
|
||||
</span>
|
||||
<span className="ml-2 text-[10px] opacity-55">{workUnit.code}</span>
|
||||
<MoreHorizontal className="ml-auto size-5 opacity-75" />
|
||||
</div>
|
||||
<button
|
||||
className="mt-5 block w-full text-left focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#315dc0]"
|
||||
onClick={handleOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowRight") {
|
||||
swipe("right");
|
||||
}
|
||||
if (event.key === "ArrowLeft") {
|
||||
swipe("left");
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
{...pointerHandlers}
|
||||
>
|
||||
<h3 className="line-clamp-2 text-[27px] leading-[30px] font-semibold tracking-[-0.045em]">
|
||||
{workUnit.title}
|
||||
</h3>
|
||||
<p className="mt-2 truncate text-[13px] opacity-60">
|
||||
Updated {workUnit.updatedLabel}
|
||||
</p>
|
||||
<div className="mt-3 border-t border-current/10 pt-3">
|
||||
<p className="text-[10px] font-semibold opacity-60">
|
||||
CURRENT STATE
|
||||
</p>
|
||||
<p className="mt-1 line-clamp-2 text-[14px] leading-5 opacity-80">
|
||||
{workUnit.summary}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 h-[7px] rounded-full bg-current/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-current"
|
||||
style={{ width: `${workUnit.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex text-[10px] font-semibold">
|
||||
<span>{workUnit.progress}% COMPLETE</span>
|
||||
<span className="ml-auto font-normal opacity-60">
|
||||
{workUnit.canRetry ? "Needs attention" : "On track"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="absolute inset-x-5 bottom-5 flex items-center border-t border-current/10 pt-4">
|
||||
<button
|
||||
aria-label={`Mark ${workUnit.title} checked`}
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-[14px] bg-white text-[#315dc0] active:scale-[0.98]"
|
||||
onClick={() => swipe("right")}
|
||||
type="button"
|
||||
>
|
||||
<Check className="size-5" />
|
||||
</button>
|
||||
<div className="ml-3 min-w-0 flex-1">
|
||||
<p className="text-[10px] font-semibold opacity-55">NEXT ACTION</p>
|
||||
<p className="truncate text-[14px] font-semibold">
|
||||
{workUnit.nextAction}
|
||||
</p>
|
||||
<p className="text-[10px] opacity-55">
|
||||
{data.artifactCount} artifacts available
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="ml-3 flex h-12 shrink-0 items-center gap-1 rounded-[17px] bg-[#c8ff00] px-4 text-[12px] font-semibold text-black active:scale-[0.98]"
|
||||
onClick={() => onOpen(workUnit.id)}
|
||||
type="button"
|
||||
>
|
||||
Open unit
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface WorkStackComposerProps {
|
||||
readonly onChange: (value: string) => void;
|
||||
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
readonly statusMessage?: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
const WorkStackComposer = ({
|
||||
onChange,
|
||||
onSubmit,
|
||||
statusMessage,
|
||||
value,
|
||||
}: WorkStackComposerProps) => (
|
||||
<footer className="relative h-[112px] shrink-0 border-t border-[#eff0ec] bg-white px-[14px] pt-[14px]">
|
||||
<form
|
||||
className="flex h-[60px] items-center rounded-[30px] bg-[#f2f3ef] p-2"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<button
|
||||
aria-label="Add attachment"
|
||||
className="flex size-11 shrink-0 items-center justify-center rounded-full bg-white text-[#5e5a56]"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-5" strokeWidth={1.8} />
|
||||
</button>
|
||||
<label className="ml-2 min-w-0 flex-1">
|
||||
<span className="sr-only">Ask Zopu or create new work</span>
|
||||
<textarea
|
||||
className="block h-5 w-full resize-none overflow-hidden bg-transparent text-[14px] leading-5 text-[#282622] outline-none placeholder:text-[#b3afab]"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="Ask Zopu or create new work..."
|
||||
rows={1}
|
||||
value={value}
|
||||
/>
|
||||
<span className="block truncate text-[10px] leading-4 text-[#c0bcb7]">
|
||||
{statusMessage ?? "Use @ to reference a work unit"}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
aria-label="Send"
|
||||
className="ml-2 flex size-11 shrink-0 items-center justify-center rounded-full bg-black text-white active:scale-[0.98]"
|
||||
type="submit"
|
||||
>
|
||||
<ArrowUp className="size-5" strokeWidth={2.2} />
|
||||
</button>
|
||||
</form>
|
||||
<span className="absolute bottom-[10px] left-1/2 h-[5px] w-[120px] -translate-x-1/2 rounded-full bg-black" />
|
||||
</footer>
|
||||
);
|
||||
|
||||
export interface MobileWorkStackScreenProps {
|
||||
readonly checkedCount: number;
|
||||
readonly composerValue: string;
|
||||
readonly data: MobileWorkspaceView;
|
||||
readonly modelId: string;
|
||||
readonly modelLabel: string;
|
||||
readonly onComposerChange: (value: string) => void;
|
||||
readonly onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
readonly onOpenUnit: (workUnitId: string) => void;
|
||||
readonly onRestoreChecked: () => void;
|
||||
readonly onSettingsClose: () => void;
|
||||
readonly onSettingsOpen: () => void;
|
||||
readonly onSignOut?: () => void;
|
||||
readonly onWorkUnitChecked: (workUnitId: string) => void;
|
||||
readonly onWorkUnitSentBack: (workUnitId: string) => void;
|
||||
readonly settingsOpen: boolean;
|
||||
readonly statusMessage?: string;
|
||||
readonly userEmail?: string;
|
||||
readonly userInitials: string;
|
||||
readonly userName?: string;
|
||||
readonly workUnits: readonly MobileWorkUnitView[];
|
||||
}
|
||||
|
||||
export const MobileWorkStackScreen = ({
|
||||
checkedCount,
|
||||
composerValue,
|
||||
data,
|
||||
modelId,
|
||||
modelLabel,
|
||||
onComposerChange,
|
||||
onComposerSubmit,
|
||||
onOpenUnit,
|
||||
onRestoreChecked,
|
||||
onSettingsClose,
|
||||
onSettingsOpen,
|
||||
onSignOut,
|
||||
onWorkUnitChecked,
|
||||
onWorkUnitSentBack,
|
||||
settingsOpen,
|
||||
statusMessage,
|
||||
userEmail,
|
||||
userInitials,
|
||||
userName,
|
||||
workUnits,
|
||||
}: MobileWorkStackScreenProps) => {
|
||||
const [currentWorkUnit, ...queuedWorkUnits] = workUnits;
|
||||
const previewWorkUnits = queuedWorkUnits.slice(0, 2).toReversed();
|
||||
const attentionLabel =
|
||||
data.needsAttentionCount === 1
|
||||
? "1 item needs attention."
|
||||
: `${data.needsAttentionCount} items need attention.`;
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto flex h-[100dvh] max-h-[858px] min-h-[700px] w-full max-w-[390px] flex-col overflow-hidden bg-white text-[#0b0b0a]">
|
||||
<WorkStackHeader
|
||||
activeCount={data.activeCount}
|
||||
onOpenSettings={onSettingsOpen}
|
||||
userInitials={userInitials}
|
||||
/>
|
||||
<main className="relative min-h-0 flex-1 overflow-hidden bg-[#f7f8f5] px-5 pt-[26px]">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-[31px] leading-9 font-semibold tracking-[-0.055em]">
|
||||
Daily focus
|
||||
</h2>
|
||||
<span className="ml-auto rounded-full bg-white px-3 py-1.5 text-[11px] font-semibold text-[#5f5b57]">
|
||||
{dateLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[14px] leading-5 text-[#807b76]">
|
||||
{data.needsAttentionCount > 0
|
||||
? `One priority is moving. ${attentionLabel}`
|
||||
: "Your active work is moving."}
|
||||
</p>
|
||||
<section
|
||||
aria-label="Active work stack"
|
||||
className="absolute inset-x-5 top-[154px] bottom-11"
|
||||
>
|
||||
{previewWorkUnits.map((workUnit, reverseIndex) => {
|
||||
const depth = previewWorkUnits.length - reverseIndex;
|
||||
return (
|
||||
<button
|
||||
aria-label={`Open ${workUnit.title}`}
|
||||
className={cn(
|
||||
"absolute inset-x-5 h-[112px] rounded-[28px] px-4 pt-4 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black",
|
||||
toneClass[workUnit.tone]
|
||||
)}
|
||||
key={workUnit.id}
|
||||
onClick={() => onOpenUnit(workUnit.id)}
|
||||
style={{
|
||||
top: `${(depth - 1) * 36}px`,
|
||||
transform: `scale(${1 - depth * 0.025})`,
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1.5 text-[10px] font-semibold",
|
||||
statusClass[workUnit.tone]
|
||||
)}
|
||||
>
|
||||
{workUnit.statusLabel.toUpperCase()}
|
||||
</span>
|
||||
<span className="ml-2 text-[10px] opacity-55">
|
||||
{workUnit.code}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{currentWorkUnit ? (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-[calc(100%-58px)]"
|
||||
key={currentWorkUnit.id}
|
||||
>
|
||||
<SwipeWorkCard
|
||||
data={data}
|
||||
onChecked={onWorkUnitChecked}
|
||||
onOpen={onOpenUnit}
|
||||
onSendBack={onWorkUnitSentBack}
|
||||
workUnit={currentWorkUnit}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-x-0 top-16 rounded-[30px] bg-white px-6 py-12 text-center">
|
||||
<Check className="mx-auto size-8 text-[#64ad1f]" />
|
||||
<h3 className="mt-3 text-[20px] font-semibold">
|
||||
Daily focus cleared
|
||||
</h3>
|
||||
<p className="mt-1 text-[13px] text-[#807b76]">
|
||||
{checkedCount} work units checked for now.
|
||||
</p>
|
||||
<button
|
||||
className="mt-5 inline-flex h-10 items-center gap-2 rounded-[14px] bg-black px-4 text-[12px] font-semibold text-white"
|
||||
onClick={onRestoreChecked}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
Restore stack
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{workUnits.length > 0 ? (
|
||||
<div className="absolute inset-x-0 bottom-4 flex justify-center gap-2">
|
||||
{workUnits.slice(0, 4).map((workUnit, index) => (
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 rounded-full bg-[#d7d6d4]",
|
||||
index === 0 ? "w-6 bg-black" : "w-1.5"
|
||||
)}
|
||||
key={workUnit.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
<WorkStackComposer
|
||||
onChange={onComposerChange}
|
||||
onSubmit={onComposerSubmit}
|
||||
statusMessage={statusMessage}
|
||||
value={composerValue}
|
||||
/>
|
||||
{settingsOpen ? (
|
||||
<MobileSettingsPanel
|
||||
modelId={modelId}
|
||||
modelLabel={modelLabel}
|
||||
onClose={onSettingsClose}
|
||||
onSignOut={onSignOut}
|
||||
userEmail={userEmail}
|
||||
userName={userName}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
156
packages/ui/src/hooks/use-swipe-card.ts
Normal file
156
packages/ui/src/hooks/use-swipe-card.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type {
|
||||
PointerEvent as ReactPointerEvent,
|
||||
TransitionEvent as ReactTransitionEvent,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export type SwipeDirection = "left" | "right";
|
||||
|
||||
interface UseSwipeCardOptions {
|
||||
readonly itemKey: string;
|
||||
readonly onSwipe: (direction: SwipeDirection) => void;
|
||||
}
|
||||
|
||||
const SWIPE_THRESHOLD = 82;
|
||||
const EXIT_DISTANCE = 460;
|
||||
const EXIT_DURATION_MS = 180;
|
||||
|
||||
export const useSwipeCard = ({ itemKey, onSwipe }: UseSwipeCardOptions) => {
|
||||
const cardRef = useRef<HTMLElement>(null);
|
||||
const checkedLabelRef = useRef<HTMLSpanElement>(null);
|
||||
const backLabelRef = useRef<HTMLSpanElement>(null);
|
||||
const startXRef = useRef(0);
|
||||
const distanceRef = useRef(0);
|
||||
const draggingRef = useRef(false);
|
||||
const swipingRef = useRef(false);
|
||||
const pendingDirectionRef = useRef<SwipeDirection | null>(null);
|
||||
const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const updateVisuals = useCallback((distance: number, animate = false) => {
|
||||
const card = cardRef.current;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
card.style.transition = animate
|
||||
? `transform ${EXIT_DURATION_MS}ms cubic-bezier(0.16, 1, 0.3, 1)`
|
||||
: "none";
|
||||
card.style.transform = `translate3d(${distance}px, 0, 0) rotate(${distance / 32}deg)`;
|
||||
const actionOpacity = Math.min(Math.abs(distance) / SWIPE_THRESHOLD, 1);
|
||||
if (checkedLabelRef.current) {
|
||||
checkedLabelRef.current.style.opacity =
|
||||
distance > 0 ? String(actionOpacity) : "0";
|
||||
}
|
||||
if (backLabelRef.current) {
|
||||
backLabelRef.current.style.opacity =
|
||||
distance < 0 ? String(actionOpacity) : "0";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (exitTimerRef.current) {
|
||||
clearTimeout(exitTimerRef.current);
|
||||
exitTimerRef.current = null;
|
||||
}
|
||||
distanceRef.current = 0;
|
||||
draggingRef.current = false;
|
||||
swipingRef.current = false;
|
||||
pendingDirectionRef.current = null;
|
||||
updateVisuals(0, true);
|
||||
}, [updateVisuals]);
|
||||
|
||||
const finishSwipe = useCallback(() => {
|
||||
const direction = pendingDirectionRef.current;
|
||||
if (!direction) {
|
||||
return;
|
||||
}
|
||||
pendingDirectionRef.current = null;
|
||||
if (exitTimerRef.current) {
|
||||
clearTimeout(exitTimerRef.current);
|
||||
exitTimerRef.current = null;
|
||||
}
|
||||
onSwipe(direction);
|
||||
}, [onSwipe]);
|
||||
|
||||
const swipe = useCallback(
|
||||
(direction: SwipeDirection) => {
|
||||
if (swipingRef.current) {
|
||||
return;
|
||||
}
|
||||
swipingRef.current = true;
|
||||
pendingDirectionRef.current = direction;
|
||||
const distance = direction === "right" ? EXIT_DISTANCE : -EXIT_DISTANCE;
|
||||
distanceRef.current = distance;
|
||||
updateVisuals(distance, true);
|
||||
exitTimerRef.current = setTimeout(finishSwipe, EXIT_DURATION_MS + 80);
|
||||
},
|
||||
[finishSwipe, updateVisuals]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
return reset;
|
||||
}, [itemKey, reset]);
|
||||
|
||||
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (
|
||||
swipingRef.current ||
|
||||
(event.pointerType === "mouse" && event.button !== 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
draggingRef.current = true;
|
||||
startXRef.current = event.clientX;
|
||||
distanceRef.current = 0;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!draggingRef.current) {
|
||||
return;
|
||||
}
|
||||
distanceRef.current = event.clientX - startXRef.current;
|
||||
updateVisuals(distanceRef.current);
|
||||
};
|
||||
|
||||
const onPointerUp = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if (!draggingRef.current) {
|
||||
return;
|
||||
}
|
||||
draggingRef.current = false;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
if (Math.abs(distanceRef.current) >= SWIPE_THRESHOLD) {
|
||||
swipe(distanceRef.current > 0 ? "right" : "left");
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
};
|
||||
|
||||
const onTransitionEnd = (event: ReactTransitionEvent<HTMLElement>) => {
|
||||
if (
|
||||
event.currentTarget === event.target &&
|
||||
event.propertyName === "transform" &&
|
||||
swipingRef.current
|
||||
) {
|
||||
finishSwipe();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
backLabelRef,
|
||||
cardRef,
|
||||
checkedLabelRef,
|
||||
dragged: () => Math.abs(distanceRef.current) > 8,
|
||||
pointerHandlers: {
|
||||
onPointerCancel: reset,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
},
|
||||
swipe,
|
||||
transitionHandlers: {
|
||||
onTransitionEnd,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
Reference in New Issue
Block a user