feat: redesign daily execution and messaging interfaces
This commit is contained in:
@@ -8,6 +8,12 @@ each dedicated workspace; its bottom avatar manages connected accounts.
|
|||||||
The interface is in English. User-entered text and external posts retain their
|
The interface is in English. User-entered text and external posts retain their
|
||||||
original language.
|
original language.
|
||||||
|
|
||||||
|
Home uses a Tiimo-inspired day flow with expandable activities and direct
|
||||||
|
controls for routine steps, tasks, replies and weight entries. Messages uses
|
||||||
|
a Beeper-inspired conversation layout with visible source messages, editable
|
||||||
|
drafts and a contextual side panel. These execution surfaces remain mocks;
|
||||||
|
their records share browser-session state and external sending is simulated.
|
||||||
|
|
||||||
## Workspaces and current status
|
## Workspaces and current status
|
||||||
|
|
||||||
| Workspace | Route | Current implementation |
|
| Workspace | Route | Current implementation |
|
||||||
|
|||||||
@@ -36,13 +36,30 @@ the new communication and journal prototypes do not consume those accounts.
|
|||||||
|
|
||||||
## Daily home
|
## Daily home
|
||||||
|
|
||||||
`/` presents a greeting, a large input, and a flexible plan grouped as
|
`/` presents a greeting, a large input, and a Tiimo-inspired flexible day flow grouped as
|
||||||
Start here, Routines, and If there’s room. There are no fixed time slots.
|
Start here, Routines, and If there’s room. There are no fixed time slots.
|
||||||
Tasks can be reordered, completed, deferred, restored, or followed as routine
|
Tasks can be reordered, completed, deferred, restored, or followed as routine
|
||||||
steps. Communications can be reviewed directly in a home dialog; changes also
|
steps. Communications can be reviewed directly in a home dialog; changes also
|
||||||
appear in `/support`. Journal entries and corrections appear on the home, and
|
appear in `/support`. Journal entries and corrections appear on the home, and
|
||||||
reader samples open for reading. Research remains a separate work surface.
|
reader samples open for reading. Research remains a separate work surface.
|
||||||
|
|
||||||
|
Activity blocks expand in place into controls suited to their content: routine
|
||||||
|
steps, a task's next action and resumption note, or a weight input that records
|
||||||
|
an entry in Journal. Completed and deferred activities stay in their original positions with visible
|
||||||
|
status and undo controls. Measurements retain the recorded value in place, and
|
||||||
|
reply previews retain their outcome, so the user need not remember what was done. Expanded activity selection
|
||||||
|
uses the `activity` URL parameter. This remains a session-only mock; a reload
|
||||||
|
restores navigation, not recorded activity data.
|
||||||
|
|
||||||
|
Messages uses a Beeper-inspired person list and visible original conversation,
|
||||||
|
with reply drafting and review next to source-backed context. On narrow screens
|
||||||
|
the list and conversation become separate views. Inbox, set-aside, waiting and
|
||||||
|
archive filters describe communication state; a simulated reply does not prove
|
||||||
|
that a separate promise was fulfilled. References include the saved
|
||||||
|
[Beeper Copilot captures](references/beeper-copilot/README.md),
|
||||||
|
[Beeper Desktop](https://blog.beeper.com/2025/02/24/try-out-the-new-beeper-desktop-and-ios-beta/),
|
||||||
|
and [open-loop research](gbrain-open-loops-research.md).
|
||||||
|
|
||||||
The input currently uses a deterministic English/Japanese mock interpreter, not an LLM.
|
The input currently uses a deterministic English/Japanese mock interpreter, not an LLM.
|
||||||
It proposes typed actions for task creation/status/reordering, routine steps,
|
It proposes typed actions for task creation/status/reordering, routine steps,
|
||||||
contact reply/review or deferral, notes/weight/medication records, and navigation.
|
contact reply/review or deferral, notes/weight/medication records, and navigation.
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
import { expect, test } from "../fixtures";
|
import { expect, test } from "../fixtures";
|
||||||
|
|
||||||
|
test("executes routine steps and records a measurement in the shared journal", async ({
|
||||||
|
page,
|
||||||
|
a11y,
|
||||||
|
}, testInfo) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.getByRole("button", { name: /Morning routine 0 of 3/ }).click();
|
||||||
|
await expect(page).toHaveURL(/activity=task/);
|
||||||
|
await page.getByRole("checkbox", { name: /Drink water/ }).check();
|
||||||
|
await expect(page.getByRole("button", { name: "Finish routine" })).toBeDisabled();
|
||||||
|
await page.getByRole("checkbox", { name: /Get ready/ }).check();
|
||||||
|
await page.getByRole("checkbox", { name: /Have breakfast/ }).check();
|
||||||
|
expect((await a11y().analyze()).violations).toEqual([]);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("home-routine.png"), fullPage: true });
|
||||||
|
await page.getByRole("button", { name: "Finish routine" }).click();
|
||||||
|
await page.getByRole("button", { name: /Record weight A quick check-in/ }).click();
|
||||||
|
await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2");
|
||||||
|
await page.getByRole("button", { name: "Save measurement" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /Record weight Recorded · 65.2 kg/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("link", { name: "Journal", exact: true }).first().click();
|
||||||
|
await expect(page.getByRole("article", { name: "Weight entry" })).toContainText("65.2 kg");
|
||||||
|
await page.getByRole("link", { name: "Home", exact: true }).first().click();
|
||||||
|
await expect(page.getByRole("button", { name: "Restore Morning routine" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the day flow and conversation context at desktop width", async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1080 });
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page.getByRole("region", { name: "Today’s plan" })).toBeVisible();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("home-desktop.png") });
|
||||||
|
await page.goto("/support?conversation=design-review");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("messages-desktop.png") });
|
||||||
|
});
|
||||||
|
|
||||||
test("previews a natural-language memo and shares the executed record with journal", async ({
|
test("previews a natural-language memo and shares the executed record with journal", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -20,7 +59,7 @@ test("previews a natural-language memo and shares the executed record with journ
|
|||||||
test("keeps the home usable on narrow screens", async ({ page, a11y }, testInfo) => {
|
test("keeps the home usable on narrow screens", async ({ page, a11y }, testInfo) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
for (const width of [320, 375, 768]) {
|
for (const width of [320, 375, 414, 768]) {
|
||||||
await page.setViewportSize({ width, height: 850 });
|
await page.setViewportSize({ width, height: 850 });
|
||||||
await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible();
|
await expect(page.getByRole("textbox", { name: "Home input" })).toBeVisible();
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width);
|
||||||
@@ -47,11 +86,9 @@ test("adds tasks only after confirmation and can defer and restore them", async
|
|||||||
await expect(complete).toBeVisible();
|
await expect(complete).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Apply", exact: true }).click();
|
await page.getByRole("button", { name: "Apply", exact: true }).click();
|
||||||
await expect(complete).toHaveCount(0);
|
await expect(complete).toHaveCount(0);
|
||||||
await page.locator(".home-rest summary").click();
|
|
||||||
await page.getByRole("button", { name: "Restore grocery shopping", exact: true }).click();
|
await page.getByRole("button", { name: "Restore grocery shopping", exact: true }).click();
|
||||||
await complete.click();
|
await complete.click();
|
||||||
await expect(complete).toHaveCount(0);
|
await expect(complete).toHaveCount(0);
|
||||||
await page.locator(".home-rest summary").click();
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: "Restore grocery shopping", exact: true }),
|
page.getByRole("button", { name: "Restore grocery shopping", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -73,13 +110,13 @@ test("resolves communication within home and carries the status to the shared qu
|
|||||||
await expect(dialog).toHaveCount(0);
|
await expect(dialog).toHaveCount(0);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: "Open conversation with Haruka Aoki" }),
|
page.getByRole("button", { name: "Open conversation with Haruka Aoki" }),
|
||||||
).toHaveCount(0);
|
).toContainText("Set aside");
|
||||||
await page.getByRole("link", { name: "Messages", exact: true }).first().click();
|
await page.getByRole("link", { name: "Messages", exact: true }).first().click();
|
||||||
const rows = page.locator(".support-row");
|
const rows = page.locator(".support-row");
|
||||||
await expect(rows.filter({ hasText: "Schedule next week’s design review" })).toHaveCount(0);
|
await expect(rows.filter({ hasText: "Schedule next week’s design review" })).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "Later", exact: true }).click();
|
await page.getByRole("button", { name: "Set aside", exact: true }).click();
|
||||||
await rows.filter({ hasText: "Schedule next week’s design review" }).click();
|
await rows.filter({ hasText: "Schedule next week’s design review" }).click();
|
||||||
await page.getByRole("button", { name: "Move to Now" }).click();
|
await page.getByRole("button", { name: "Move to Inbox" }).click();
|
||||||
const back = page.getByRole("button", { name: "Back to messages" });
|
const back = page.getByRole("button", { name: "Back to messages" });
|
||||||
if (await back.isVisible()) await back.click();
|
if (await back.isVisible()) await back.click();
|
||||||
await page.getByRole("link", { name: "Home", exact: true }).first().click();
|
await page.getByRole("link", { name: "Home", exact: true }).first().click();
|
||||||
@@ -95,7 +132,7 @@ test("updates the prose brief as priorities change and opens the referenced conv
|
|||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
const brief = page.getByRole("region", { name: "Daily brief" });
|
const brief = page.getByRole("region", { name: "Daily brief" });
|
||||||
await expect(brief).toContainText("Review the documents");
|
await expect(brief).toContainText("Review the documents");
|
||||||
await expect(brief).toContainText("4 conversations");
|
await expect(brief).toContainText("3 conversations");
|
||||||
await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
|
await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
|
||||||
await expect(brief).not.toContainText("Review the documents");
|
await expect(brief).not.toContainText("Review the documents");
|
||||||
await brief.getByRole("button", { name: "Reply to Haruka Aoki" }).click();
|
await brief.getByRole("button", { name: "Reply to Haruka Aoki" }).click();
|
||||||
@@ -104,10 +141,53 @@ test("updates the prose brief as priorities change and opens the referenced conv
|
|||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
await dialog.getByRole("button", { name: "Leave for later" }).click();
|
await dialog.getByRole("button", { name: "Leave for later" }).click();
|
||||||
await expect(brief).toContainText("3 conversations");
|
await expect(brief).toContainText("2 conversations");
|
||||||
await expect(brief).not.toContainText("Haruka Aoki");
|
await expect(brief).not.toContainText("Haruka Aoki");
|
||||||
await page.getByRole("textbox", { name: "Home input" }).fill("再開地点:資料の2ページ目から");
|
await page.getByRole("textbox", { name: "Home input" }).fill("再開地点:資料の2ページ目から");
|
||||||
await page.getByRole("button", { name: "Preview action" }).click();
|
await page.getByRole("button", { name: "Preview action" }).click();
|
||||||
await page.getByRole("button", { name: "Apply", exact: true }).click();
|
await page.getByRole("button", { name: "Apply", exact: true }).click();
|
||||||
await expect(brief).toContainText("資料の2ページ目から");
|
await expect(brief).toContainText("資料の2ページ目から");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps completed actions in their original positions with visible outcomes", async ({
|
||||||
|
page,
|
||||||
|
a11y,
|
||||||
|
}, testInfo) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
const titles = page.locator(".execution-flow-title");
|
||||||
|
const before = await titles.allTextContents();
|
||||||
|
await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Restore Review the documents", exact: true }),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Review the documents Completed", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Open conversation with Haruka Aoki" }).click();
|
||||||
|
const conversation = page.getByRole("dialog", {
|
||||||
|
name: "Conversation with Haruka Aoki",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await conversation.getByRole("button", { name: "Review reply", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("dialog", { name: "Review reply", exact: true })
|
||||||
|
.getByRole("button", { name: "Try sending" })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Open conversation with Haruka Aoki" }),
|
||||||
|
).toContainText("Reply recorded");
|
||||||
|
await page.getByRole("button", { name: /Record weight A quick check-in/ }).click();
|
||||||
|
await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2");
|
||||||
|
await page.getByRole("button", { name: "Save measurement" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: /Record weight Recorded · 65.2 kg/ }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(await titles.allTextContents()).toEqual(before);
|
||||||
|
expect((await a11y().analyze()).violations).toEqual([]);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("completion-in-place.png") });
|
||||||
|
await page.getByRole("button", { name: "Restore Review the documents", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Complete Review the documents", exact: true }),
|
||||||
|
).toHaveAttribute("aria-pressed", "false");
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ test("reviews an edited reply and keeps the simulated result in history", async
|
|||||||
await expect(reply).toHaveValue(/Thank you for sharing/);
|
await expect(reply).toHaveValue(/Thank you for sharing/);
|
||||||
const edited = "I reviewed the materials. Let’s discuss the first-time experience.";
|
const edited = "I reviewed the materials. Let’s discuss the first-time experience.";
|
||||||
await reply.fill(edited);
|
await reply.fill(edited);
|
||||||
|
await page.locator(".conversation-scheduling summary:visible").click();
|
||||||
const slot = "Mon, Oct 5, 14:00–14:30";
|
const slot = "Mon, Oct 5, 14:00–14:30";
|
||||||
await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot);
|
await page.getByRole("combobox", { name: "Suggested times" }).selectOption(slot);
|
||||||
await page.getByRole("button", { name: "Add to reply" }).click();
|
await page.getByRole("button", { name: "Add to reply" }).click();
|
||||||
@@ -33,7 +34,7 @@ test("reviews an edited reply and keeps the simulated result in history", async
|
|||||||
await dialog.getByRole("button", { name: "Try sending" }).click();
|
await dialog.getByRole("button", { name: "Try sending" }).click();
|
||||||
await expect(dialog).toHaveCount(0);
|
await expect(dialog).toHaveCount(0);
|
||||||
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0);
|
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "History", exact: true }).click();
|
await page.getByRole("button", { name: "Archive", exact: true }).click();
|
||||||
await rows.filter({ hasText: reviewSubject }).click();
|
await rows.filter({ hasText: reviewSubject }).click();
|
||||||
await expect(page.locator(".support-outcome")).toContainText(edited);
|
await expect(page.locator(".support-outcome")).toContainText(edited);
|
||||||
await expect(page.locator(".support-outcome")).toContainText(slot);
|
await expect(page.locator(".support-outcome")).toContainText(slot);
|
||||||
@@ -49,16 +50,16 @@ test("defers a conversation and restores it to the current queue", async ({ page
|
|||||||
.fill("土曜の予定を確認してから返事するね。");
|
.fill("土曜の予定を確認してから返事するね。");
|
||||||
await page.getByRole("button", { name: "Leave for later" }).click();
|
await page.getByRole("button", { name: "Leave for later" }).click();
|
||||||
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0);
|
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0);
|
||||||
await page.getByRole("button", { name: "Later", exact: true }).click();
|
await page.getByRole("button", { name: "Set aside", exact: true }).click();
|
||||||
await rows.filter({ hasText: coffeeSubject }).click();
|
await rows.filter({ hasText: coffeeSubject }).click();
|
||||||
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toHaveValue(
|
await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toHaveValue(
|
||||||
"土曜の予定を確認してから返事するね。",
|
"土曜の予定を確認してから返事するね。",
|
||||||
);
|
);
|
||||||
await page.getByRole("button", { name: "Move to Now" }).click();
|
await page.getByRole("button", { name: "Move to Inbox" }).click();
|
||||||
const back = page.getByRole("button", { name: "Back to messages" });
|
const back = page.getByRole("button", { name: "Back to messages" });
|
||||||
if (await back.isVisible()) await back.click();
|
if (await back.isVisible()) await back.click();
|
||||||
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible();
|
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Now", exact: true })).toHaveAttribute(
|
await expect(page.getByRole("button", { name: "Inbox", exact: true })).toHaveAttribute(
|
||||||
"aria-pressed",
|
"aria-pressed",
|
||||||
"true",
|
"true",
|
||||||
);
|
);
|
||||||
@@ -107,7 +108,7 @@ test("keeps corrections as new records and summarizes only selected current fact
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("fits communication and journal interactions in narrow screens", async ({ page, a11y }) => {
|
test("fits communication and journal interactions in narrow screens", async ({ page, a11y }) => {
|
||||||
for (const width of [320, 375, 768]) {
|
for (const width of [320, 375, 414, 768]) {
|
||||||
await page.setViewportSize({ width, height: 850 });
|
await page.setViewportSize({ width, height: 850 });
|
||||||
await page.goto("/support");
|
await page.goto("/support");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ export function DailyBrief({ onOpenContact }: { onOpenContact: (id: string) => v
|
|||||||
const { tasks, statuses, entries } = useWorkspaceState();
|
const { tasks, statuses, entries } = useWorkspaceState();
|
||||||
const focus = tasks.find((task) => task.section === "focus" && task.status === "todo");
|
const focus = tasks.find((task) => task.section === "focus" && task.status === "todo");
|
||||||
const optional = tasks.find((task) => task.section === "optional" && task.status === "todo");
|
const optional = tasks.find((task) => task.section === "optional" && task.status === "todo");
|
||||||
const contacts = conversations.filter((person) => (statuses[person.id] ?? "now") === "now");
|
const contacts = conversations.filter(
|
||||||
|
(person) =>
|
||||||
|
(statuses[person.id] ?? "now") === "now" &&
|
||||||
|
(person.replyRequired || statuses[person.id] === "now"),
|
||||||
|
);
|
||||||
const firstContact = contacts[0];
|
const firstContact = contacts[0];
|
||||||
const resume = entries.find(
|
const resume = entries.find(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/* Flexible day flow: quiet activity blocks, with detail where the action happens. */
|
||||||
|
.execution-day {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.execution-flow-group {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.execution-flow-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.execution-flow-heading h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.execution-flow-heading > span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.execution-flow-items {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
.execution-flow-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--muted);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.execution-flow-card[data-kind="focus"] {
|
||||||
|
background: color-mix(in oklch, var(--color-activity-lilac) 16%, var(--background));
|
||||||
|
}
|
||||||
|
.execution-flow-card[data-kind="routine"] {
|
||||||
|
background: color-mix(in oklch, var(--color-activity-sage) 16%, var(--background));
|
||||||
|
}
|
||||||
|
.execution-flow-card[data-kind="reply"] {
|
||||||
|
background: color-mix(in oklch, var(--color-activity-lilac) 9%, var(--background));
|
||||||
|
}
|
||||||
|
.execution-flow-card[data-kind="weight"] {
|
||||||
|
background: color-mix(in oklch, var(--color-activity-peach) 15%, var(--background));
|
||||||
|
}
|
||||||
|
.execution-flow-card[data-kind="optional"] {
|
||||||
|
background: color-mix(in oklch, var(--color-activity-sage) 9%, var(--background));
|
||||||
|
}
|
||||||
|
.execution-flow-card[data-expanded="true"] {
|
||||||
|
border-color: color-mix(in oklch, var(--foreground) 28%, var(--border));
|
||||||
|
}
|
||||||
|
.execution-flow-card[data-status="done"] {
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
.execution-flow-card[data-status="done"] .execution-flow-title {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.execution-flow-card[data-status="done"] .execution-complete,
|
||||||
|
.execution-flow-card[data-status="done"] .execution-flow-icon {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
.execution-flow-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 13px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.execution-complete,
|
||||||
|
.execution-flow-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
height: 28px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.execution-complete {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.execution-complete:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.execution-complete:hover:not(:disabled) {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.execution-complete svg,
|
||||||
|
.execution-flow-icon svg {
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
.execution-flow-open {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: left;
|
||||||
|
gap: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.execution-flow-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.execution-flow-description {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.execution-expand-icon {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.execution-flow-card[data-expanded="true"] > .execution-flow-row > .execution-expand-icon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.execution-inline-detail {
|
||||||
|
padding: 6px 22px 24px 55px;
|
||||||
|
}
|
||||||
|
.execution-inline-detail h2 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.execution-detail-symbol,
|
||||||
|
.execution-eyebrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.execution-description {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
.execution-routine-progress {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin: 20px 0 8px;
|
||||||
|
}
|
||||||
|
.execution-step-list {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.execution-step-list label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.execution-step-list label > .execution-step-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.execution-step-list label[data-checked="true"] > .execution-step-text {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.execution-step-number {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.execution-next-step {
|
||||||
|
padding: 14px 0;
|
||||||
|
margin-top: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.execution-next-step > span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.execution-next-step p {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.75;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.execution-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.execution-resume-note {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 25px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.execution-resume-note > label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.execution-resume-note textarea {
|
||||||
|
min-height: 74px;
|
||||||
|
background: var(--background);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.execution-note-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.execution-note-footer > span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.execution-order {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 20px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.execution-order > span {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.execution-weight-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.execution-weight-form > label {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.execution-weight-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
.execution-weight-input input {
|
||||||
|
background: var(--background);
|
||||||
|
font-size: 20px;
|
||||||
|
height: 42px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.execution-weight-input > span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.execution-weight-form > p {
|
||||||
|
width: 100%;
|
||||||
|
color: var(--destructive);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.execution-source-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.execution-flow-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
.execution-rest {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.execution-rest summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.execution-rest summary > span {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.execution-rest-items {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.execution-rest-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.execution-rest-row > svg {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.execution-rest-row > span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.execution-rest-row small {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
.execution-flow-open:focus-visible,
|
||||||
|
.execution-complete:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.execution-flow-row {
|
||||||
|
padding: 15px 14px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.execution-flow-heading > span {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.execution-inline-detail {
|
||||||
|
padding: 4px 18px 20px;
|
||||||
|
}
|
||||||
|
.execution-flow-title {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.execution-flow-description {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.execution-note-footer {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowUpRight,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Circle,
|
||||||
|
ListChecks,
|
||||||
|
MessageCircle,
|
||||||
|
Pause,
|
||||||
|
RotateCcw,
|
||||||
|
Scale,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "#/components/ui/button";
|
||||||
|
import { Checkbox } from "#/components/ui/checkbox";
|
||||||
|
import { Input } from "#/components/ui/input";
|
||||||
|
import { Textarea } from "#/components/ui/textarea";
|
||||||
|
import { conversations } from "#/features/support/mock-conversations";
|
||||||
|
import {
|
||||||
|
type JournalEntry,
|
||||||
|
type Task,
|
||||||
|
useWorkspaceState,
|
||||||
|
} from "#/features/workspace/workspace-state";
|
||||||
|
|
||||||
|
type ExecutionWorkbenchProps = {
|
||||||
|
selectedId?: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onOpenContact: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ExecutionWorkbench({
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onOpenContact,
|
||||||
|
}: ExecutionWorkbenchProps) {
|
||||||
|
const { tasks, setTasks, statuses, entries } = useWorkspaceState();
|
||||||
|
const currentEntries = entries.filter(
|
||||||
|
(entry) => !entries.some((other) => other.correctionOf === entry.id),
|
||||||
|
);
|
||||||
|
const weightEntry = currentEntries.find((entry) => entry.kind === "体重");
|
||||||
|
const replies = conversations.filter(
|
||||||
|
(conversation) => conversation.replyRequired || statuses[conversation.id] === "now",
|
||||||
|
);
|
||||||
|
function toggle(id: string) {
|
||||||
|
onSelect(selectedId === id ? "" : id);
|
||||||
|
}
|
||||||
|
function finish(id: string) {
|
||||||
|
setTasks((current) =>
|
||||||
|
current.map((task) =>
|
||||||
|
task.id === id ? { ...task, status: task.status === "todo" ? "done" : "todo" } : task,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="execution-day" aria-label="Today’s plan">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ id: "focus", label: "Start here", subtitle: "Just the next thing" },
|
||||||
|
{ id: "routine", label: "Routines", subtitle: "Find your rhythm" },
|
||||||
|
{ id: "optional", label: "If there’s room", subtitle: "No need to fit it all in" },
|
||||||
|
] as const
|
||||||
|
).map((section) => (
|
||||||
|
<section className="execution-flow-group" key={section.id}>
|
||||||
|
<header className="execution-flow-heading">
|
||||||
|
<h2>{section.label}</h2>
|
||||||
|
</header>
|
||||||
|
<div className="execution-flow-items">
|
||||||
|
{tasks
|
||||||
|
.filter((task) => task.section === section.id)
|
||||||
|
.map((task) => {
|
||||||
|
const id = `task:${task.id}`;
|
||||||
|
const expanded = selectedId === id;
|
||||||
|
const steps = task.steps ?? [];
|
||||||
|
const checked = steps.filter((step) => task.completedSteps?.includes(step)).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="execution-flow-card"
|
||||||
|
key={task.id}
|
||||||
|
data-expanded={expanded}
|
||||||
|
data-kind={task.section}
|
||||||
|
data-status={task.status}
|
||||||
|
>
|
||||||
|
<div className="execution-flow-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="execution-complete"
|
||||||
|
aria-label={`${task.status === "todo" ? "Complete" : "Restore"} ${task.title}`}
|
||||||
|
aria-pressed={task.status === "done"}
|
||||||
|
disabled={
|
||||||
|
task.status === "todo" && steps.length > 0 && checked < steps.length
|
||||||
|
}
|
||||||
|
onClick={() => finish(task.id)}
|
||||||
|
>
|
||||||
|
{task.status === "done" ? (
|
||||||
|
<Check />
|
||||||
|
) : task.status === "later" ? (
|
||||||
|
<Pause />
|
||||||
|
) : (
|
||||||
|
<Circle />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="execution-flow-open"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls={`detail-${task.id}`}
|
||||||
|
onClick={() => toggle(id)}
|
||||||
|
>
|
||||||
|
<span className="execution-flow-title">{task.title}</span>
|
||||||
|
<span className="execution-flow-description">
|
||||||
|
{task.status === "done"
|
||||||
|
? "Completed"
|
||||||
|
: task.status === "later"
|
||||||
|
? "Set aside"
|
||||||
|
: steps.length
|
||||||
|
? `${checked} of ${steps.length} steps · ${steps.find((step) => !task.completedSteps?.includes(step)) ?? "Ready to finish"}`
|
||||||
|
: task.id === "review"
|
||||||
|
? "Start with the first screen"
|
||||||
|
: "At your own pace"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<ChevronDown className="execution-expand-icon" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="execution-inline-detail" id={`detail-${task.id}`}>
|
||||||
|
<TaskDetail task={task} onAction={() => onSelect("")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{section.id === "focus" &&
|
||||||
|
replies.slice(0, 2).map((conversation) => (
|
||||||
|
<div
|
||||||
|
className="execution-flow-card"
|
||||||
|
key={conversation.id}
|
||||||
|
data-kind="reply"
|
||||||
|
data-status={
|
||||||
|
statuses[conversation.id] === "sent" ||
|
||||||
|
statuses[conversation.id] === "dismissed"
|
||||||
|
? "done"
|
||||||
|
: statuses[conversation.id]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="execution-flow-row">
|
||||||
|
<span className="execution-flow-icon">
|
||||||
|
{statuses[conversation.id] === "sent" ||
|
||||||
|
statuses[conversation.id] === "dismissed" ? (
|
||||||
|
<Check />
|
||||||
|
) : (
|
||||||
|
<MessageCircle />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="execution-flow-open"
|
||||||
|
aria-label={`Open conversation with ${conversation.name}`}
|
||||||
|
onClick={() => onOpenContact(conversation.id)}
|
||||||
|
>
|
||||||
|
<span className="execution-flow-title">Reply to {conversation.name}</span>
|
||||||
|
<span className="execution-flow-description">
|
||||||
|
{statuses[conversation.id] === "sent"
|
||||||
|
? "Reply recorded · Preview only"
|
||||||
|
: statuses[conversation.id] === "dismissed"
|
||||||
|
? "No reply needed"
|
||||||
|
: statuses[conversation.id] === "later"
|
||||||
|
? "Set aside"
|
||||||
|
: statuses[conversation.id] === "waiting"
|
||||||
|
? "Waiting for their reply"
|
||||||
|
: conversation.subject}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<ArrowUpRight className="execution-expand-icon" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{section.id === "routine" && (
|
||||||
|
<div
|
||||||
|
className="execution-flow-card"
|
||||||
|
data-expanded={selectedId === "weight"}
|
||||||
|
data-kind="weight"
|
||||||
|
data-status={weightEntry ? "done" : "todo"}
|
||||||
|
>
|
||||||
|
<div className="execution-flow-row">
|
||||||
|
<span className="execution-flow-icon">{weightEntry ? <Check /> : <Scale />}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="execution-flow-open"
|
||||||
|
aria-expanded={selectedId === "weight"}
|
||||||
|
aria-controls="detail-weight"
|
||||||
|
onClick={() => toggle("weight")}
|
||||||
|
>
|
||||||
|
<span className="execution-flow-title">Record weight</span>
|
||||||
|
<span className="execution-flow-description">
|
||||||
|
{weightEntry ? `Recorded · ${weightEntry.text} kg` : "A quick check-in"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<ChevronDown className="execution-expand-icon" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
{selectedId === "weight" && (
|
||||||
|
<div className="execution-inline-detail" id="detail-weight">
|
||||||
|
<WeightDetail
|
||||||
|
key={weightEntry?.id ?? "new"}
|
||||||
|
entry={weightEntry}
|
||||||
|
onAction={() => onSelect("")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!tasks.some((task) => task.section === section.id && task.status === "todo") &&
|
||||||
|
(section.id !== "focus" || !replies.length) &&
|
||||||
|
section.id !== "routine" && (
|
||||||
|
<p className="execution-flow-empty">A little breathing room.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function TaskDetail({ task, onAction }: { task: Task; onAction: () => void }) {
|
||||||
|
const { setTasks, entries, setEntries } = useWorkspaceState();
|
||||||
|
const source = `task:${task.id}`;
|
||||||
|
const savedNote = entries.find((entry) => entry.kind === "再開地点" && entry.source === source);
|
||||||
|
const [note, setNote] = useState(savedNote?.text ?? "");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
const steps = task.steps ?? [];
|
||||||
|
const checked = steps.filter((step) => task.completedSteps?.includes(step)).length;
|
||||||
|
function setStatus(status: Task["status"]) {
|
||||||
|
setTasks((current) =>
|
||||||
|
current.map((item) => (item.id === task.id ? { ...item, status } : item)),
|
||||||
|
);
|
||||||
|
onAction();
|
||||||
|
}
|
||||||
|
function move(direction: -1 | 1) {
|
||||||
|
setTasks((current) => {
|
||||||
|
const position = current.findIndex((item) => item.id === task.id);
|
||||||
|
const siblings = current
|
||||||
|
.map((item, index) => ({ item, index }))
|
||||||
|
.filter(({ item }) => item.section === task.section && item.status === task.status);
|
||||||
|
const sibling = siblings.findIndex(({ item }) => item.id === task.id);
|
||||||
|
const target = siblings[sibling + direction];
|
||||||
|
const origin = current[position];
|
||||||
|
if (!target || !origin) return current;
|
||||||
|
const updated = [...current];
|
||||||
|
updated[position] = target.item;
|
||||||
|
updated[target.index] = origin;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="execution-detail-symbol">
|
||||||
|
<ListChecks />
|
||||||
|
</div>
|
||||||
|
<p className="execution-eyebrow">
|
||||||
|
{task.section === "routine"
|
||||||
|
? "Your routine"
|
||||||
|
: task.section === "optional"
|
||||||
|
? "If there’s room"
|
||||||
|
: "One next step"}
|
||||||
|
</p>
|
||||||
|
<h2>{task.title}</h2>
|
||||||
|
{steps.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="execution-routine-progress">
|
||||||
|
<span>
|
||||||
|
{checked} of {steps.length} steps
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{task.status === "done"
|
||||||
|
? "Completed"
|
||||||
|
: checked === steps.length
|
||||||
|
? "Ready to finish"
|
||||||
|
: "At your pace"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="execution-step-list">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<label
|
||||||
|
key={step}
|
||||||
|
htmlFor={`execution-step-${task.id}-${index}`}
|
||||||
|
data-checked={task.completedSteps?.includes(step) ?? false}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`execution-step-${task.id}-${index}`}
|
||||||
|
checked={task.completedSteps?.includes(step) ?? false}
|
||||||
|
disabled={task.status === "done"}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
setTasks((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === task.id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
completedSteps: value
|
||||||
|
? Array.from(new Set([...(item.completedSteps ?? []), step]))
|
||||||
|
: (item.completedSteps ?? []).filter(
|
||||||
|
(completed) => completed !== step,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="execution-step-text">{step}</span>
|
||||||
|
<span className="execution-step-number">{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="execution-description">
|
||||||
|
{task.id === "review"
|
||||||
|
? "Look through the first-time experience. Leave a short note on anything that feels unclear."
|
||||||
|
: task.section === "optional"
|
||||||
|
? "There’s room to decide later. Start whenever it feels right."
|
||||||
|
: "Choose a small first step. You can leave a note if you need to pause."}
|
||||||
|
</p>
|
||||||
|
{task.id === "review" && (
|
||||||
|
<div className="execution-next-step">
|
||||||
|
<span>Start here</span>
|
||||||
|
<p>Open the materials and look at the first screen.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="execution-detail-actions">
|
||||||
|
{task.status !== "done" && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setStatus("done")}
|
||||||
|
disabled={steps.length > 0 && checked < steps.length}
|
||||||
|
>
|
||||||
|
<Check /> {steps.length > 0 ? "Finish routine" : "Mark complete"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{task.status === "todo" && (
|
||||||
|
<Button variant="outline" onClick={() => setStatus("later")}>
|
||||||
|
<Pause /> Later
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{task.status !== "todo" && (
|
||||||
|
<Button variant="outline" onClick={() => setStatus("todo")}>
|
||||||
|
<RotateCcw /> Bring back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<section className="execution-resume-note" aria-label="Resume note">
|
||||||
|
<label htmlFor={`resume-${task.id}`}>A place to pick up</label>
|
||||||
|
<Textarea
|
||||||
|
id={`resume-${task.id}`}
|
||||||
|
placeholder="Where did you leave off?"
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => {
|
||||||
|
setNote(event.target.value);
|
||||||
|
setNotice("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="execution-note-footer">
|
||||||
|
<span role="status">{notice || "Leave a sentence for your future self."}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={!note.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
setEntries((current) => [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
kind: "再開地点",
|
||||||
|
text: note.trim(),
|
||||||
|
source,
|
||||||
|
occurredAt: "",
|
||||||
|
recordedAt: new Date().toISOString(),
|
||||||
|
...(savedNote ? { correctionOf: savedNote.id } : {}),
|
||||||
|
},
|
||||||
|
...current,
|
||||||
|
]);
|
||||||
|
setNotice("Saved");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="execution-order">
|
||||||
|
<span>Order in your list</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Move ${task.title} up`}
|
||||||
|
onClick={() => move(-1)}
|
||||||
|
>
|
||||||
|
<ArrowUp />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Move ${task.title} down`}
|
||||||
|
onClick={() => move(1)}
|
||||||
|
>
|
||||||
|
<ArrowDown />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WeightDetail({ entry, onAction }: { entry?: JournalEntry; onAction: () => void }) {
|
||||||
|
const { setEntries } = useWorkspaceState();
|
||||||
|
const [weight, setWeight] = useState(entry?.text ?? "");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="execution-detail-symbol">
|
||||||
|
<Scale />
|
||||||
|
</div>
|
||||||
|
<p className="execution-eyebrow">A quick check-in</p>
|
||||||
|
<h2>Record your weight</h2>
|
||||||
|
<p className="execution-description">Just a measurement. No target to hit.</p>
|
||||||
|
<form
|
||||||
|
className="execution-weight-form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const value = Number(weight);
|
||||||
|
if (!weight.trim() || !Number.isFinite(value) || value <= 0 || value > 1000) {
|
||||||
|
setError("Enter a weight greater than 0 and no more than 1,000 kg.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEntries((current) => [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
kind: "体重",
|
||||||
|
text: String(value),
|
||||||
|
source: "Home check-in",
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
recordedAt: new Date().toISOString(),
|
||||||
|
...(entry ? { correctionOf: entry.id } : {}),
|
||||||
|
},
|
||||||
|
...current,
|
||||||
|
]);
|
||||||
|
setError("");
|
||||||
|
onAction();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label htmlFor="execution-weight">Weight</label>
|
||||||
|
<div className="execution-weight-input">
|
||||||
|
<Input
|
||||||
|
id="execution-weight"
|
||||||
|
inputMode="decimal"
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max="1000"
|
||||||
|
step="any"
|
||||||
|
value={weight}
|
||||||
|
required
|
||||||
|
aria-invalid={Boolean(error)}
|
||||||
|
aria-describedby={error ? "execution-weight-error" : undefined}
|
||||||
|
onChange={(event) => {
|
||||||
|
setWeight(event.target.value);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>kg</span>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p id="execution-weight-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit">
|
||||||
|
<Check /> {entry ? "Save correction" : "Save measurement"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{entry && (
|
||||||
|
<div className="execution-next-step">
|
||||||
|
<span>Recorded</span>
|
||||||
|
<p>{entry.text} kg</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="execution-source-note">Your measurement also appears in Journal.</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,8 @@
|
|||||||
import { Link, useNavigate, useRouteContext } from "@tanstack/react-router";
|
import { Link, useNavigate, useRouteContext } from "@tanstack/react-router";
|
||||||
import {
|
import { ArrowUp, ArrowUpRight, Plus, X } from "lucide-react";
|
||||||
ArrowDown,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowUpRight,
|
|
||||||
Check,
|
|
||||||
ChevronRight,
|
|
||||||
Circle,
|
|
||||||
Coffee,
|
|
||||||
MessagesSquare,
|
|
||||||
Plus,
|
|
||||||
RotateCcw,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Dialog } from "#/components/dialog";
|
import { Dialog } from "#/components/dialog";
|
||||||
import { Button } from "#/components/ui/button";
|
import { Button } from "#/components/ui/button";
|
||||||
import { Checkbox } from "#/components/ui/checkbox";
|
|
||||||
import { SidebarInset, SidebarProvider } from "#/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "#/components/ui/sidebar";
|
||||||
import { Textarea } from "#/components/ui/textarea";
|
import { Textarea } from "#/components/ui/textarea";
|
||||||
import { TooltipProvider } from "#/components/ui/tooltip";
|
import { TooltipProvider } from "#/components/ui/tooltip";
|
||||||
@@ -26,11 +13,12 @@ import { ConversationDetail } from "#/features/support/conversation-detail";
|
|||||||
import { conversations } from "#/features/support/mock-conversations";
|
import { conversations } from "#/features/support/mock-conversations";
|
||||||
import { entryKindLabels, useWorkspaceState } from "#/features/workspace/workspace-state";
|
import { entryKindLabels, useWorkspaceState } from "#/features/workspace/workspace-state";
|
||||||
import { DailyBrief } from "./daily-brief";
|
import { DailyBrief } from "./daily-brief";
|
||||||
|
import { ExecutionWorkbench } from "./execution-workbench";
|
||||||
import { type HomeAction, interpretAction } from "./interpret-action";
|
import { type HomeAction, interpretAction } from "./interpret-action";
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { auth } = useRouteContext({ from: "__root__" });
|
const { auth } = useRouteContext({ from: "__root__" });
|
||||||
const { tasks, setTasks, statuses, setStatuses, outcomes, setOutcomes, entries, setEntries } =
|
const { tasks, setTasks, setStatuses, outcomes, setOutcomes, entries, setEntries } =
|
||||||
useWorkspaceState();
|
useWorkspaceState();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const input = useRef<HTMLTextAreaElement>(null);
|
const input = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -49,11 +37,9 @@ export function HomePage() {
|
|||||||
updateSearch({ article: id, conversation: undefined });
|
updateSearch({ article: id, conversation: undefined });
|
||||||
const contact = conversations.find((item) => item.id === contactId);
|
const contact = conversations.find((item) => item.id === contactId);
|
||||||
const article = articles.find((item) => item.id === articleId);
|
const article = articles.find((item) => item.id === articleId);
|
||||||
const waiting = conversations.filter((item) => (statuses[item.id] ?? "now") === "now");
|
|
||||||
const currentEntries = entries.filter(
|
const currentEntries = entries.filter(
|
||||||
(entry) => !entries.some((other) => other.correctionOf === entry.id),
|
(entry) => !entries.some((other) => other.correctionOf === entry.id),
|
||||||
);
|
);
|
||||||
const rest = tasks.filter((task) => task.status !== "todo");
|
|
||||||
|
|
||||||
function suggest(text: string) {
|
function suggest(text: string) {
|
||||||
setPrompt(text);
|
setPrompt(text);
|
||||||
@@ -230,215 +216,12 @@ export function HomePage() {
|
|||||||
{notice}
|
{notice}
|
||||||
</p>
|
</p>
|
||||||
<div className="home-columns">
|
<div className="home-columns">
|
||||||
<section aria-label="Today’s plan">
|
<ExecutionWorkbench
|
||||||
{(
|
selectedId={search.activity}
|
||||||
[
|
onSelect={(id) => updateSearch({ activity: id || undefined })}
|
||||||
{ id: "focus", label: "Start here" },
|
onOpenContact={setContactId}
|
||||||
{ id: "routine", label: "Routines" },
|
/>
|
||||||
{ id: "optional", label: "If there’s room" },
|
|
||||||
] as const
|
|
||||||
).map((section) => (
|
|
||||||
<section key={section.id} className="home-section">
|
|
||||||
<header className="home-section-heading">
|
|
||||||
<h2>{section.label}</h2>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Add to ${section.label}`}
|
|
||||||
onClick={() =>
|
|
||||||
suggest(
|
|
||||||
section.id === "routine"
|
|
||||||
? "Add routine:"
|
|
||||||
: section.id === "optional"
|
|
||||||
? "Optional: "
|
|
||||||
: "Add:",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus />
|
|
||||||
</Button>
|
|
||||||
</header>
|
|
||||||
{tasks
|
|
||||||
.filter((task) => task.section === section.id && task.status === "todo")
|
|
||||||
.map((task) => (
|
|
||||||
<div className="home-task" key={task.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="home-check"
|
|
||||||
aria-label={`Complete ${task.title}`}
|
|
||||||
onClick={() =>
|
|
||||||
setTasks((current) =>
|
|
||||||
current.map((item) =>
|
|
||||||
item.id === task.id ? { ...item, status: "done" } : item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Circle className="size-5" />
|
|
||||||
</button>
|
|
||||||
<div className="home-task-body">
|
|
||||||
<p>{task.title}</p>
|
|
||||||
{task.steps && (
|
|
||||||
<details>
|
|
||||||
<summary>One step at a time</summary>
|
|
||||||
<div className="home-steps">
|
|
||||||
{task.steps.map((step) => (
|
|
||||||
<label
|
|
||||||
key={step}
|
|
||||||
htmlFor={`step-${task.id}-${step}`}
|
|
||||||
className="flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={`step-${task.id}-${step}`}
|
|
||||||
checked={task.completedSteps?.includes(step) ?? false}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setTasks((current) =>
|
|
||||||
current.map((item) =>
|
|
||||||
item.id === task.id
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
completedSteps: checked
|
|
||||||
? [...(item.completedSteps ?? []), step]
|
|
||||||
: (item.completedSteps ?? []).filter(
|
|
||||||
(value) => value !== step,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>{step}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="home-task-actions">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Move ${task.title} up`}
|
|
||||||
onClick={() => moveTask(task.id, -1)}
|
|
||||||
>
|
|
||||||
<ArrowUp />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Move ${task.title} down`}
|
|
||||||
onClick={() => moveTask(task.id, 1)}
|
|
||||||
>
|
|
||||||
<ArrowDown />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
aria-label={`Defer ${task.title}`}
|
|
||||||
onClick={() =>
|
|
||||||
setTasks((current) =>
|
|
||||||
current.map((item) =>
|
|
||||||
item.id === task.id ? { ...item, status: "later" } : item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Later
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{section.id === "focus" &&
|
|
||||||
waiting.slice(0, 2).map((item) => (
|
|
||||||
<div className="home-task" key={item.id}>
|
|
||||||
<MessagesSquare className="size-5 text-muted-foreground mt-1" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="home-task-body text-left"
|
|
||||||
onClick={() => setContactId(item.id)}
|
|
||||||
>
|
|
||||||
<span className="block">Reply to {item.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{item.subject}</span>
|
|
||||||
</button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Open conversation with ${item.name}`}
|
|
||||||
onClick={() => setContactId(item.id)}
|
|
||||||
>
|
|
||||||
<ChevronRight />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!tasks.some(
|
|
||||||
(task) => task.section === section.id && task.status === "todo",
|
|
||||||
) &&
|
|
||||||
(section.id !== "focus" || !waiting.length) && (
|
|
||||||
<p className="home-empty">It’s okay to leave some room.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
{rest.length > 0 && (
|
|
||||||
<details className="home-rest">
|
|
||||||
<summary>
|
|
||||||
Later & done <span>{rest.length}</span>
|
|
||||||
</summary>
|
|
||||||
{rest.map((task) => (
|
|
||||||
<div className="home-task" key={task.id}>
|
|
||||||
{task.status === "done" ? (
|
|
||||||
<Check className="size-4" />
|
|
||||||
) : (
|
|
||||||
<Coffee className="size-4" />
|
|
||||||
)}
|
|
||||||
<span className="flex-1 text-sm">{task.title}</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Restore ${task.title}`}
|
|
||||||
onClick={() =>
|
|
||||||
setTasks((current) =>
|
|
||||||
current.map((item) =>
|
|
||||||
item.id === task.id ? { ...item, status: "todo" } : item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RotateCcw />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
<aside aria-label="Today’s context">
|
<aside aria-label="Today’s context">
|
||||||
<section className="home-section">
|
|
||||||
<header className="home-section-heading">
|
|
||||||
<h2>Messages</h2>
|
|
||||||
<Link to="/support" aria-label="View all messages">
|
|
||||||
<ArrowUpRight className="size-4" />
|
|
||||||
</Link>
|
|
||||||
</header>
|
|
||||||
{conversations
|
|
||||||
.filter((item) => !["sent", "dismissed"].includes(statuses[item.id] ?? "now"))
|
|
||||||
.map((item) => (
|
|
||||||
<button
|
|
||||||
className="home-context-row"
|
|
||||||
type="button"
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => setContactId(item.id)}
|
|
||||||
>
|
|
||||||
<span>{item.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{statuses[item.id] === "waiting"
|
|
||||||
? "Waiting"
|
|
||||||
: statuses[item.id] === "later"
|
|
||||||
? "Later"
|
|
||||||
: item.channels.join(" / ")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
<section className="home-section">
|
<section className="home-section">
|
||||||
<header className="home-section-heading">
|
<header className="home-section-heading">
|
||||||
<h2>Journal</h2>
|
<h2>Journal</h2>
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.home-content {
|
.home-content {
|
||||||
max-width: 1140px;
|
max-width: 1200px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
padding: 64px 48px 80px;
|
padding: 40px 48px 80px;
|
||||||
}
|
}
|
||||||
.home-greeting {
|
.home-greeting {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
@@ -88,8 +88,8 @@
|
|||||||
}
|
}
|
||||||
.home-columns {
|
.home-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.65fr) minmax(240px, 1fr);
|
grid-template-columns: minmax(0, 2.5fr) minmax(190px, 1fr);
|
||||||
gap: 64px;
|
gap: 40px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
.home-section {
|
.home-section {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const flag = z.literal(true).optional().catch(undefined);
|
|||||||
const workspaceSearch = z.object({
|
const workspaceSearch = z.object({
|
||||||
q: text,
|
q: text,
|
||||||
article: text,
|
article: text,
|
||||||
|
activity: text,
|
||||||
conversation: text,
|
conversation: text,
|
||||||
view: z.enum(["now", "later", "waiting", "done", "all"]).optional().catch(undefined),
|
view: z.enum(["now", "later", "waiting", "done", "all"]).optional().catch(undefined),
|
||||||
accounts: flag,
|
accounts: flag,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CalendarPlus, Check, ChevronDown, Send } from "lucide-react";
|
import { CalendarPlus, Check, ChevronDown, CornerDownRight, Send } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useId, useState } from "react";
|
||||||
import { Button } from "#/components/ui/button";
|
import { Button } from "#/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -20,182 +20,199 @@ export function ConversationDetail({
|
|||||||
conversation: Conversation;
|
conversation: Conversation;
|
||||||
onResolve: (result: "sent" | "waiting" | "later" | "dismissed", note?: string) => void;
|
onResolve: (result: "sent" | "waiting" | "later" | "dismissed", note?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const instanceId = useId();
|
||||||
const [draft, setDraft] = useState(conversation.drafts[0]?.text ?? "");
|
const [draft, setDraft] = useState(conversation.drafts[0]?.text ?? "");
|
||||||
const [tone, setTone] = useState(conversation.drafts[0]?.label ?? "");
|
const [tone, setTone] = useState(conversation.drafts[0]?.label ?? "");
|
||||||
const [review, setReview] = useState<"send" | "schedule" | null>(null);
|
const [review, setReview] = useState<"send" | "schedule" | null>(null);
|
||||||
const [slot, setSlot] = useState("");
|
const [slot, setSlot] = useState("");
|
||||||
const [scheduled, setScheduled] = useState("");
|
const [scheduled, setScheduled] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className="mx-auto w-full max-w-3xl px-5 py-8 sm:px-10 sm:py-10"
|
className="conversation-workbench"
|
||||||
aria-label={`Conversation with ${conversation.name}`}
|
aria-label={`Conversation with ${conversation.name}`}
|
||||||
>
|
>
|
||||||
<header className="mb-8 space-y-2">
|
<div className="conversation-thread">
|
||||||
<p className="text-xs text-muted-foreground">
|
<header className="conversation-summary">
|
||||||
{conversation.channels.join(" / ")} · {conversation.role}
|
<span className="conversation-eyebrow">At a glance</span>
|
||||||
</p>
|
<h2>{conversation.subject}</h2>
|
||||||
<h1 className="text-2xl font-medium tracking-tight">{conversation.name}</h1>
|
<p>{conversation.summary}</p>
|
||||||
<p className="text-sm text-muted-foreground">{conversation.subject}</p>
|
</header>
|
||||||
</header>
|
<section className="conversation-messages" aria-label="Original messages">
|
||||||
|
|
||||||
<section className="mb-8 space-y-4" aria-label="Conversation summary">
|
|
||||||
<p className="text-sm leading-7">{conversation.summary}</p>
|
|
||||||
<div className="border-l-2 border-border pl-4">
|
|
||||||
<p className="text-sm leading-6">{conversation.nextStep}</p>
|
|
||||||
{conversation.deadline && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Requested deadline · {conversation.deadline}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<details className="group mb-8 border-y border-border py-3">
|
|
||||||
<summary className="flex cursor-pointer list-none items-center justify-between text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
||||||
Original messages{" "}
|
|
||||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
|
|
||||||
</summary>
|
|
||||||
<div className="space-y-5 pt-5">
|
|
||||||
{conversation.messages.map((message) => (
|
{conversation.messages.map((message) => (
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
key={message.id}
|
||||||
id={`source-${message.id}`}
|
id={`${instanceId}-source-${message.id}`}
|
||||||
className="scroll-mt-5 rounded-lg bg-muted/40 p-4 focus:outline-none"
|
className="conversation-message"
|
||||||
|
data-direction={message.direction}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<p className="mb-2 text-xs text-muted-foreground">
|
<p className="conversation-message-meta">
|
||||||
{message.direction === "in" ? conversation.name : "You"} · {message.channel} ·{" "}
|
{message.direction === "in" ? conversation.name.split(" ")[0] : "You"}
|
||||||
{message.time}
|
<span>
|
||||||
|
{message.channel} · {message.time}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="whitespace-pre-wrap text-sm leading-7">{message.text}</p>
|
<div className="conversation-bubble">{message.text}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</section>
|
||||||
</details>
|
<section aria-label="Compose a reply" className="conversation-compose">
|
||||||
|
<div className="conversation-compose-heading">
|
||||||
<section aria-label="Compose a reply" className="space-y-3">
|
<label htmlFor={`${instanceId}-reply`}>Reply</label>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<span>via {conversation.replyChannel}</span>
|
||||||
<label htmlFor={`support-reply-${conversation.id}`} className="text-sm font-medium">
|
|
||||||
Reply
|
|
||||||
</label>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{conversation.replyChannel} → {conversation.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{conversation.drafts.map((option) => (
|
|
||||||
<Button
|
|
||||||
key={option.label}
|
|
||||||
variant={tone === option.label ? "secondary" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
aria-pressed={tone === option.label}
|
|
||||||
onClick={() => {
|
|
||||||
setDraft(option.text);
|
|
||||||
setTone(option.label);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
id={`support-reply-${conversation.id}`}
|
|
||||||
value={draft}
|
|
||||||
className="min-h-36 resize-y text-sm leading-7"
|
|
||||||
onChange={(event) => {
|
|
||||||
setDraft(event.target.value);
|
|
||||||
setTone("");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{conversation.scheduleOptions.length > 0 && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<NativeSelect
|
|
||||||
aria-label="Suggested times"
|
|
||||||
value={slot}
|
|
||||||
onChange={(event) => setSlot(event.target.value)}
|
|
||||||
>
|
|
||||||
<NativeSelectOption value="">Choose a time</NativeSelectOption>
|
|
||||||
{conversation.scheduleOptions.map((option) => (
|
|
||||||
<NativeSelectOption key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</NativeSelectOption>
|
|
||||||
))}
|
|
||||||
</NativeSelect>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={!slot}
|
|
||||||
onClick={() => {
|
|
||||||
setDraft((current) => `${current}\n\nWould ${slot} work for you?`);
|
|
||||||
setTone("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add to reply
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={!slot}
|
|
||||||
aria-label="Preview calendar hold"
|
|
||||||
onClick={() => setReview("schedule")}
|
|
||||||
>
|
|
||||||
<CalendarPlus />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="conversation-draft-options" aria-label="Reply suggestions">
|
||||||
{scheduled && (
|
{conversation.drafts.map((option) => (
|
||||||
<p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
|
<button
|
||||||
<Check className="size-3" />
|
key={option.label}
|
||||||
{scheduled} held in this preview
|
type="button"
|
||||||
</p>
|
aria-pressed={tone === option.label}
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
|
||||||
<Button disabled={!draft.trim()} onClick={() => setReview("send")}>
|
|
||||||
<Send />
|
|
||||||
Review reply
|
|
||||||
</Button>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => onResolve("later")}>
|
|
||||||
Leave for later
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => onResolve("waiting")}>
|
|
||||||
Waiting
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => onResolve("dismissed")}>
|
|
||||||
Close without replying
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<details className="group mt-10 border-t border-border pt-4">
|
|
||||||
<summary className="flex cursor-pointer list-none items-center justify-between text-xs text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
|
||||||
About this person{" "}
|
|
||||||
<ChevronDown className="size-3 transition-transform group-open:rotate-180" />
|
|
||||||
</summary>
|
|
||||||
<ul className="mt-4 space-y-3 text-sm leading-6">
|
|
||||||
{conversation.facts.map((fact) => (
|
|
||||||
<li key={fact.sourceId} className="flex items-start justify-between gap-3">
|
|
||||||
<span>{fact.text}</span>
|
|
||||||
<a
|
|
||||||
className="shrink-0 text-xs text-muted-foreground underline underline-offset-4"
|
|
||||||
href={`#source-${fact.sourceId}`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const source = document.getElementById(`source-${fact.sourceId}`);
|
setDraft(option.text);
|
||||||
const details = source?.closest("details");
|
setTone(option.label);
|
||||||
if (details) details.open = true;
|
|
||||||
source?.focus();
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Source
|
{option.label}
|
||||||
</a>
|
</button>
|
||||||
</li>
|
))}
|
||||||
))}
|
</div>
|
||||||
</ul>
|
<Textarea
|
||||||
</details>
|
id={`${instanceId}-reply`}
|
||||||
|
value={draft}
|
||||||
|
className="min-h-28 resize-y text-sm leading-7"
|
||||||
|
onChange={(event) => {
|
||||||
|
setDraft(event.target.value);
|
||||||
|
setTone("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{conversation.scheduleOptions.length > 0 && (
|
||||||
|
<details className="conversation-scheduling">
|
||||||
|
<summary>
|
||||||
|
<CalendarPlus aria-hidden="true" />
|
||||||
|
Suggest a time
|
||||||
|
<ChevronDown aria-hidden="true" />
|
||||||
|
</summary>
|
||||||
|
<div>
|
||||||
|
<NativeSelect
|
||||||
|
aria-label="Suggested times"
|
||||||
|
value={slot}
|
||||||
|
onChange={(event) => setSlot(event.target.value)}
|
||||||
|
>
|
||||||
|
<NativeSelectOption value="">Choose a time</NativeSelectOption>
|
||||||
|
{conversation.scheduleOptions.map((option) => (
|
||||||
|
<NativeSelectOption key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!slot}
|
||||||
|
onClick={() => {
|
||||||
|
setDraft((current) => `${current}\n\nWould ${slot} work for you?`);
|
||||||
|
setTone("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add to reply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!slot}
|
||||||
|
aria-label="Preview calendar hold"
|
||||||
|
onClick={() => setReview("schedule")}
|
||||||
|
>
|
||||||
|
<CalendarPlus />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{scheduled && (
|
||||||
|
<p role="status" className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Check className="size-3" />
|
||||||
|
{scheduled} held in this preview
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="conversation-compose-footer">
|
||||||
|
<span>Nothing sends without review</span>
|
||||||
|
<Button disabled={!draft.trim()} onClick={() => setReview("send")}>
|
||||||
|
<Send />
|
||||||
|
Review reply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="conversation-triage">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Leave for later"
|
||||||
|
onClick={() => onResolve("later")}
|
||||||
|
>
|
||||||
|
Set aside
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onResolve("waiting")}>
|
||||||
|
Awaiting reply
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onResolve("dismissed")}>
|
||||||
|
No reply needed
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<aside className="conversation-context" aria-label="Person and conversation context">
|
||||||
|
<details open className="conversation-context-details">
|
||||||
|
<summary>
|
||||||
|
Context
|
||||||
|
<ChevronDown aria-hidden="true" />
|
||||||
|
</summary>
|
||||||
|
<div className="conversation-person">
|
||||||
|
<span className="support-avatar" aria-hidden="true">
|
||||||
|
{conversation.name
|
||||||
|
.split(" ")
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")}
|
||||||
|
</span>
|
||||||
|
<h3>{conversation.name}</h3>
|
||||||
|
<p>{conversation.role}</p>
|
||||||
|
</div>
|
||||||
|
<section className="conversation-next">
|
||||||
|
<h3>
|
||||||
|
<CornerDownRight aria-hidden="true" />
|
||||||
|
Next step
|
||||||
|
</h3>
|
||||||
|
<p>{conversation.nextStep}</p>
|
||||||
|
{conversation.deadline && (
|
||||||
|
<div className="conversation-deadline">
|
||||||
|
<span>Requested by</span>
|
||||||
|
<strong>{conversation.deadline}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section className="conversation-facts">
|
||||||
|
<h3>From the conversation</h3>
|
||||||
|
<ul>
|
||||||
|
{conversation.facts.map((fact) => (
|
||||||
|
<li key={fact.sourceId}>
|
||||||
|
<p>{fact.text}</p>
|
||||||
|
<a
|
||||||
|
href={`#${instanceId}-source-${fact.sourceId}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const source = document.getElementById(
|
||||||
|
`${instanceId}-source-${fact.sourceId}`,
|
||||||
|
);
|
||||||
|
source?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
source?.focus({ preventScroll: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View message ↗
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</details>
|
||||||
|
</aside>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={review !== null}
|
open={review !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type Conversation = {
|
|||||||
subject: string;
|
subject: string;
|
||||||
channels: Channel[];
|
channels: Channel[];
|
||||||
replyChannel: Channel;
|
replyChannel: Channel;
|
||||||
|
replyRequired: boolean;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deadline?: string;
|
deadline?: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -32,6 +33,7 @@ export const conversations: Conversation[] = [
|
|||||||
subject: "Schedule next week’s design review",
|
subject: "Schedule next week’s design review",
|
||||||
channels: ["Slack", "Gmail"],
|
channels: ["Slack", "Gmail"],
|
||||||
replyChannel: "Slack",
|
replyChannel: "Slack",
|
||||||
|
replyRequired: true,
|
||||||
updatedAt: "10:42",
|
updatedAt: "10:42",
|
||||||
deadline: "Sep 29, 17:00",
|
deadline: "Sep 29, 17:00",
|
||||||
summary:
|
summary:
|
||||||
@@ -86,6 +88,7 @@ export const conversations: Conversation[] = [
|
|||||||
subject: "Catch up over coffee",
|
subject: "Catch up over coffee",
|
||||||
channels: ["LINE"],
|
channels: ["LINE"],
|
||||||
replyChannel: "LINE",
|
replyChannel: "LINE",
|
||||||
|
replyRequired: true,
|
||||||
updatedAt: "Yesterday",
|
updatedAt: "Yesterday",
|
||||||
summary: "An invitation to meet for coffee while Naoto is nearby. There is no rush to reply.",
|
summary: "An invitation to meet for coffee while Naoto is nearby. There is no rush to reply.",
|
||||||
nextStep:
|
nextStep:
|
||||||
@@ -128,6 +131,7 @@ export const conversations: Conversation[] = [
|
|||||||
subject: "Confirm the scope for an estimate",
|
subject: "Confirm the scope for an estimate",
|
||||||
channels: ["Gmail"],
|
channels: ["Gmail"],
|
||||||
replyChannel: "Gmail",
|
replyChannel: "Gmail",
|
||||||
|
replyRequired: true,
|
||||||
updatedAt: "9:10",
|
updatedAt: "9:10",
|
||||||
deadline: "Sep 30, 12:00",
|
deadline: "Sep 30, 12:00",
|
||||||
summary:
|
summary:
|
||||||
@@ -171,6 +175,7 @@ export const conversations: Conversation[] = [
|
|||||||
subject: "The article we talked about",
|
subject: "The article we talked about",
|
||||||
channels: ["Slack"],
|
channels: ["Slack"],
|
||||||
replyChannel: "Slack",
|
replyChannel: "Slack",
|
||||||
|
replyRequired: false,
|
||||||
updatedAt: "Friday",
|
updatedAt: "Friday",
|
||||||
summary:
|
summary:
|
||||||
"Yu shared an article discussed at the last study group. There are no questions or requests for a reply.",
|
"Yu shared an article discussed at the last study group. There are no questions or requests for a reply.",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowLeft, Search } from "lucide-react";
|
import { ArrowLeft, MessageSquare, Search } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "#/components/ui/button";
|
import { Button } from "#/components/ui/button";
|
||||||
import { Input } from "#/components/ui/input";
|
import { Input } from "#/components/ui/input";
|
||||||
@@ -18,67 +18,75 @@ import { ConversationDetail } from "./conversation-detail";
|
|||||||
import { conversations } from "./mock-conversations";
|
import { conversations } from "./mock-conversations";
|
||||||
|
|
||||||
const views = [
|
const views = [
|
||||||
{ id: "now", label: "Now" },
|
{ id: "now", label: "Inbox" },
|
||||||
{ id: "later", label: "Later" },
|
|
||||||
{ id: "waiting", label: "Waiting" },
|
|
||||||
{ id: "done", label: "History" },
|
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All" },
|
||||||
|
{ id: "later", label: "Set aside" },
|
||||||
|
{ id: "waiting", label: "Awaiting reply" },
|
||||||
|
{ id: "done", label: "Archive" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function SupportPage() {
|
export function SupportPage() {
|
||||||
const [search, updateSearch] = useUrlState();
|
const [search, updateSearch] = useUrlState();
|
||||||
const view = search.view ?? "now";
|
const view = search.view ?? "now";
|
||||||
const query = search.q ?? "";
|
const query = search.q ?? "";
|
||||||
const searchState = useSearchInput("conversation");
|
const searchState = useSearchInput("conversation");
|
||||||
const searchInput = {
|
|
||||||
value: searchState.value,
|
|
||||||
onChange: searchState.onChange,
|
|
||||||
onCompositionStart: searchState.onCompositionStart,
|
|
||||||
onCompositionEnd: searchState.onCompositionEnd,
|
|
||||||
};
|
|
||||||
const { statuses, setStatuses, outcomes, setOutcomes } = useWorkspaceState();
|
const { statuses, setStatuses, outcomes, setOutcomes } = useWorkspaceState();
|
||||||
const selectedId = search.conversation;
|
const selectedId = search.conversation;
|
||||||
const reading = !!selectedId;
|
|
||||||
const [notice, setNotice] = useState("");
|
const [notice, setNotice] = useState("");
|
||||||
const matches = conversations.filter((item) => {
|
const visible = conversations.filter((item) => {
|
||||||
const status = statuses[item.id] ?? "now";
|
const status = statuses[item.id] ?? "now";
|
||||||
return (
|
return (
|
||||||
(view === "all"
|
(view !== "now" || item.replyRequired || statuses[item.id] === "now") &&
|
||||||
? true
|
(view === "all" ||
|
||||||
: view === "done"
|
(view === "done" ? status === "sent" || status === "dismissed" : status === view)) &&
|
||||||
? status === "sent" || status === "dismissed"
|
`${item.name} ${item.subject} ${item.channels.join(" ")}`
|
||||||
: status === view) && `${item.name} ${item.subject}`.includes(query)
|
.toLowerCase()
|
||||||
|
.includes(query.toLowerCase())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const visible = view === "now" ? matches.slice(0, 3) : matches;
|
|
||||||
const selected = visible.find((item) => item.id === selectedId) ?? visible[0];
|
const selected = visible.find((item) => item.id === selectedId) ?? visible[0];
|
||||||
function resolve(id: string, status: Exclude<Status, "now">, note?: string) {
|
function resolve(id: string, status: Exclude<Status, "now">, note?: string) {
|
||||||
setStatuses((current) => ({ ...current, [id]: status }));
|
setStatuses((current) => ({ ...current, [id]: status }));
|
||||||
if (note) setOutcomes((current) => ({ ...current, [id]: note }));
|
if (note) setOutcomes((current) => ({ ...current, [id]: note }));
|
||||||
setNotice(
|
setNotice(
|
||||||
status === "sent"
|
status === "sent"
|
||||||
? "Reply preview recorded"
|
? "Reply preview recorded. No message sent."
|
||||||
: status === "later"
|
: status === "later"
|
||||||
? "Moved to Later"
|
? "Set aside for later"
|
||||||
: status === "waiting"
|
: status === "waiting"
|
||||||
? "Moved to Waiting"
|
? "Awaiting their reply"
|
||||||
: "Marked as no action needed",
|
: "Archived without replying",
|
||||||
);
|
);
|
||||||
updateSearch({ conversation: undefined });
|
updateSearch({ conversation: undefined });
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<SidebarProvider className="support-workspace" data-reading={reading}>
|
<SidebarProvider className="support-workspace" data-reading={!!selectedId}>
|
||||||
<Sidebar collapsible="none" className="support-sidebar">
|
<Sidebar collapsible="none" className="support-sidebar">
|
||||||
<WorkspaceNavigation />
|
<WorkspaceNavigation />
|
||||||
<Sidebar collapsible="none" className="support-list">
|
<Sidebar collapsible="none" className="support-list">
|
||||||
<SidebarHeader className="border-b p-5 gap-4">
|
<SidebarHeader className="p-5 gap-4">
|
||||||
<h1 className="text-base font-semibold">Messages</h1>
|
<div className="support-list-title">
|
||||||
<div className="flex gap-1 flex-wrap">
|
<h1>Messages</h1>
|
||||||
|
<span>{visible.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
aria-label="Search messages"
|
||||||
|
placeholder="Search people or messages"
|
||||||
|
className="pl-9"
|
||||||
|
value={searchState.value}
|
||||||
|
onChange={searchState.onChange}
|
||||||
|
onCompositionStart={searchState.onCompositionStart}
|
||||||
|
onCompositionEnd={searchState.onCompositionEnd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<nav className="support-filters" aria-label="Conversation filters">
|
||||||
{views.map((tab) => (
|
{views.map((tab) => (
|
||||||
<Button
|
<button
|
||||||
|
type="button"
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
size="sm"
|
|
||||||
variant={view === tab.id ? "secondary" : "ghost"}
|
|
||||||
aria-pressed={view === tab.id}
|
aria-pressed={view === tab.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
updateSearch({
|
updateSearch({
|
||||||
@@ -89,18 +97,9 @@ export function SupportPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</Button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</nav>
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
aria-label="Search messages"
|
|
||||||
placeholder="Search"
|
|
||||||
className="pl-9"
|
|
||||||
{...searchInput}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent className="gap-0">
|
<SidebarContent className="gap-0">
|
||||||
{visible.map((item) => (
|
{visible.map((item) => (
|
||||||
@@ -109,23 +108,33 @@ export function SupportPage() {
|
|||||||
className="support-row"
|
className="support-row"
|
||||||
aria-current={selected?.id === item.id ? "true" : undefined}
|
aria-current={selected?.id === item.id ? "true" : undefined}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => updateSearch({ conversation: item.id })}
|
||||||
updateSearch({ conversation: item.id });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="text-xs text-muted-foreground">{item.name}</span>
|
<span className="support-avatar" aria-hidden="true">
|
||||||
<span className="font-medium text-sm">{item.subject}</span>
|
{item.name
|
||||||
|
.split(" ")
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")}
|
||||||
|
</span>
|
||||||
|
<span className="support-row-content">
|
||||||
|
<span className="support-row-heading">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<span>{item.updatedAt}</span>
|
||||||
|
</span>
|
||||||
|
<span className="support-row-preview">{item.subject}</span>
|
||||||
|
<span className="support-row-channel">{item.channels.join(" · ")}</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{!visible.length && (
|
{!visible.length && (
|
||||||
<p className="p-5 text-sm text-muted-foreground">
|
<p className="p-5 text-sm text-muted-foreground">
|
||||||
{query ? "No results found" : "No messages to handle right now"}
|
{query ? "No results found" : "Nothing here. You’re all caught up."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{view === "now" && matches.length > 3 && (
|
|
||||||
<p className="p-5 text-xs text-muted-foreground">{matches.length - 3} more</p>
|
|
||||||
)}
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
<div className="support-list-footer" role="status">
|
||||||
|
{notice || "Sample conversations"}
|
||||||
|
</div>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SidebarInset className="support-main">
|
<SidebarInset className="support-main">
|
||||||
@@ -139,54 +148,55 @@ export function SupportPage() {
|
|||||||
>
|
>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm">{selected?.name}</span>
|
{selected && (
|
||||||
<span role="status" className="ml-auto text-xs text-muted-foreground">
|
<>
|
||||||
{notice}
|
<span className="support-avatar support-avatar-small" aria-hidden="true">
|
||||||
</span>
|
{selected.name
|
||||||
|
.split(" ")
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{selected.name}</strong>
|
||||||
|
<p>{selected.channels.join(" · ")}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selected && view !== "now" && (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStatuses((current) => ({ ...current, [selected.id]: "now" }));
|
||||||
|
updateSearch({ view: undefined, conversation: selected.id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Move to Inbox
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<section
|
<section className="support-detail" aria-label="Message details">
|
||||||
className="support-detail"
|
|
||||||
aria-label="Message details"
|
|
||||||
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Independent region needs keyboard scrolling.
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{conversations.map((conversation) => (
|
{conversations.map((conversation) => (
|
||||||
<div key={conversation.id} hidden={selected?.id !== conversation.id}>
|
<div key={conversation.id} hidden={selected?.id !== conversation.id}>
|
||||||
<ConversationDetail
|
<ConversationDetail
|
||||||
conversation={conversation}
|
conversation={conversation}
|
||||||
onResolve={(status, note) => resolve(conversation.id, status, note)}
|
onResolve={(status, note) => resolve(conversation.id, status, note)}
|
||||||
/>
|
/>
|
||||||
</div>
|
{outcomes[conversation.id] && (
|
||||||
))}
|
|
||||||
{selected ? (
|
|
||||||
<>
|
|
||||||
{outcomes[selected.id] && (
|
|
||||||
<div className="support-outcome">
|
<div className="support-outcome">
|
||||||
<h2 className="font-medium text-sm">Activity log</h2>
|
<h2>Reply preview</h2>
|
||||||
<p className="whitespace-pre-wrap mt-2 text-sm">{outcomes[selected.id]}</p>
|
<p>{outcomes[conversation.id]}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{view !== "now" && (
|
</div>
|
||||||
<Button
|
))}
|
||||||
variant="outline"
|
{!selected && (
|
||||||
className="m-6"
|
<div className="support-empty">
|
||||||
onClick={() => {
|
<MessageSquare aria-hidden="true" />
|
||||||
setStatuses((current) => ({
|
<p>Select a conversation</p>
|
||||||
...current,
|
</div>
|
||||||
[selected.id]: "now",
|
|
||||||
}));
|
|
||||||
updateSearch({
|
|
||||||
view: undefined,
|
|
||||||
conversation: selected.id,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Move to Now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="support-empty">Select a conversation</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/* Hallmark · sidebar workbench · neutral · P5 H5 E4 S5 R5 V4 */
|
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 · existing neutral tokens */
|
||||||
.support-workspace {
|
.support-workspace {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.support-sidebar {
|
.support-sidebar {
|
||||||
width: 390px;
|
width: 370px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
@@ -15,22 +15,126 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
.support-list-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.support-list-title h1 {
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
.support-list-title > span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.support-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.support-filters button,
|
||||||
|
.conversation-draft-options button {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.support-filters button[aria-pressed="true"],
|
||||||
|
.conversation-draft-options button[aria-pressed="true"] {
|
||||||
|
background: var(--sidebar-accent);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.support-filters button:hover,
|
||||||
|
.conversation-draft-options button:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
.support-row {
|
.support-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 12px;
|
||||||
gap: 10px;
|
padding: 17px 14px;
|
||||||
padding: 22px 20px;
|
margin: 2px 8px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-radius: 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
line-height: 1.6;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.support-row:hover {
|
.support-row:hover {
|
||||||
background: var(--sidebar-accent);
|
background: var(--sidebar-accent);
|
||||||
}
|
}
|
||||||
.support-row[aria-current="true"] {
|
.support-row[aria-current="true"] {
|
||||||
|
background: var(--sidebar-accent);
|
||||||
|
}
|
||||||
|
.support-row:focus-visible,
|
||||||
|
.support-filters button:focus-visible,
|
||||||
|
.conversation-draft-options button:focus-visible,
|
||||||
|
.conversation-scheduling summary:focus-visible,
|
||||||
|
.conversation-context summary:focus-visible {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.support-row:active {
|
||||||
background: var(--muted);
|
background: var(--muted);
|
||||||
box-shadow: inset 2px 0 var(--primary);
|
}
|
||||||
|
.support-avatar {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.support-avatar-small {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.support-row-content {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.support-row-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.support-row-heading strong {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.support-row-heading > span {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.support-row-preview {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.support-row-channel {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.support-list-footer {
|
||||||
|
padding: 16px 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
.support-main {
|
.support-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -40,33 +144,283 @@
|
|||||||
.support-toolbar {
|
.support-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
min-height: 56px;
|
min-height: 70px;
|
||||||
padding: 12px 24px;
|
padding: 12px 28px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
.support-toolbar strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.support-toolbar p {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
.support-detail {
|
.support-detail {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
container-type: inline-size;
|
||||||
.support-detail > * {
|
|
||||||
max-width: 800px;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
}
|
||||||
.support-outcome {
|
.support-outcome {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.support-outcome p {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
.support-empty {
|
.support-empty {
|
||||||
padding: 64px 24px;
|
padding: 64px 24px;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.support-back {
|
.support-back {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.conversation-workbench {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.conversation-thread {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.conversation-summary {
|
||||||
|
padding: 26px 30px 22px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.conversation-eyebrow {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.conversation-summary h2 {
|
||||||
|
margin-top: 7px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 550;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
.conversation-summary > p {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
max-width: 64ch;
|
||||||
|
}
|
||||||
|
.conversation-messages {
|
||||||
|
padding: 28px 30px 34px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.conversation-message {
|
||||||
|
max-width: 90%;
|
||||||
|
border-radius: 12px;
|
||||||
|
scroll-margin: 24px;
|
||||||
|
}
|
||||||
|
.conversation-message:focus {
|
||||||
|
outline: 2px solid var(--ring);
|
||||||
|
outline-offset: 5px;
|
||||||
|
}
|
||||||
|
.conversation-message[data-direction="out"] {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
.conversation-message-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.conversation-message-meta span {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.conversation-bubble {
|
||||||
|
padding: 14px 17px;
|
||||||
|
border-radius: 3px 14px 14px;
|
||||||
|
background: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.conversation-message[data-direction="out"] .conversation-bubble {
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 14px 3px 14px 14px;
|
||||||
|
}
|
||||||
|
.conversation-compose {
|
||||||
|
padding: 20px 30px 24px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.conversation-compose-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.conversation-compose-heading label {
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.conversation-compose-heading span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.conversation-draft-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.conversation-compose-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.conversation-compose-footer > span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.conversation-triage {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.conversation-scheduling summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.conversation-scheduling summary svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
.conversation-scheduling > div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.conversation-context {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
min-width: 0;
|
||||||
|
padding: 22px;
|
||||||
|
background: var(--sidebar);
|
||||||
|
}
|
||||||
|
.conversation-context-details > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.conversation-context-details > summary svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.conversation-person {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
padding: 25px 0;
|
||||||
|
}
|
||||||
|
.conversation-person h3 {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.conversation-person p {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.conversation-next {
|
||||||
|
padding: 20px 0;
|
||||||
|
border-block: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.conversation-next h3,
|
||||||
|
.conversation-facts h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.conversation-next h3 svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
.conversation-next > p {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.conversation-deadline {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.conversation-deadline span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.conversation-deadline strong {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.conversation-next > .conversation-next-note {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.conversation-facts {
|
||||||
|
padding-top: 22px;
|
||||||
|
}
|
||||||
|
.conversation-facts ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
.conversation-facts li p {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.conversation-facts a {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
@container (min-width: 780px) {
|
||||||
|
.conversation-workbench {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 250px;
|
||||||
|
min-height: calc(100dvh - 70px);
|
||||||
|
}
|
||||||
|
.conversation-context {
|
||||||
|
border-top: 0;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.support-sidebar {
|
.support-sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -86,4 +440,22 @@
|
|||||||
.support-toolbar {
|
.support-toolbar {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
.conversation-summary,
|
||||||
|
.conversation-compose {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.conversation-messages {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
.conversation-message {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.conversation-compose-footer > span {
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.conversation-message {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,5 @@
|
|||||||
@import "./features/journal/journal.css";
|
@import "./features/journal/journal.css";
|
||||||
@import "./features/support/support.css";
|
@import "./features/support/support.css";
|
||||||
@import "./features/home/home.css";
|
@import "./features/home/home.css";
|
||||||
|
@import "./features/home/execution-workbench.css";
|
||||||
@import "./features/vitals/vitals.css";
|
@import "./features/vitals/vitals.css";
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
--color-control-rule: var(--input);
|
--color-control-rule: var(--input);
|
||||||
--color-focus: var(--ring);
|
--color-focus: var(--ring);
|
||||||
--color-error: var(--destructive);
|
--color-error: var(--destructive);
|
||||||
|
--color-activity-lilac: oklch(78% 0.09 295);
|
||||||
|
--color-activity-sage: oklch(80% 0.07 155);
|
||||||
|
--color-activity-peach: oklch(82% 0.08 60);
|
||||||
--color-success: oklch(78% 0.13 155);
|
--color-success: oklch(78% 0.13 155);
|
||||||
--color-shadow: oklch(10% 0.015 250 / 45%);
|
--color-shadow: oklch(10% 0.015 250 / 45%);
|
||||||
--color-overlay: oklch(10% 0.015 250 / 70%);
|
--color-overlay: oklch(10% 0.015 250 / 70%);
|
||||||
|
|||||||
Reference in New Issue
Block a user