fix(web): close final workbench interaction gaps

This commit is contained in:
2026-07-22 01:24:51 +08:00
parent 26c3e6fd8a
commit b1ce837845
23 changed files with 808 additions and 175 deletions

View File

@@ -281,6 +281,10 @@
display: none;
}
.mobile-search-button {
display: none;
}
.nav-backdrop {
display: none;
}
@@ -346,6 +350,21 @@
width: 48px;
}
.project-stack-scroll {
width: 64px;
min-height: 0;
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
display: flex;
justify-content: center;
scrollbar-width: thin;
}
.project-stack-scroll .project-stack {
flex: 0 0 auto;
}
.rail-brand {
width: 48px;
height: 48px;
@@ -357,6 +376,8 @@
border-radius: var(--radius-control);
background: transparent;
cursor: pointer;
flex: 0 0 48px;
min-height: 48px;
}
.rail-brand .brand-icon {
@@ -370,6 +391,8 @@
width: 48px !important;
height: 48px !important;
min-width: 48px;
min-height: 48px;
flex: 0 0 48px;
padding: 0 !important;
border-radius: var(--radius-control);
}
@@ -1835,7 +1858,7 @@
}
.cron-row {
grid-template-columns: minmax(180px, 1fr) 64px 92px 92px 92px 48px 40px;
grid-template-columns: minmax(180px, 1fr) 64px 92px 110px 110px 56px;
}
.cron-summary .arco-card-body {
@@ -2212,6 +2235,44 @@
}
}
@media (max-width: 1179px) {
.channel-nav-button {
display: inline-flex;
min-width: 40px;
min-height: 40px;
}
.channel-sidebar {
position: fixed !important;
top: 58px;
bottom: 32px;
left: 96px;
z-index: 20;
height: auto !important;
max-width: calc(100vw - 96px);
visibility: hidden;
pointer-events: none;
transform: translateX(-110%);
transition: transform 160ms ease, visibility 160ms ease;
}
.channel-sidebar.open {
visibility: visible;
pointer-events: auto;
transform: translateX(0);
}
.nav-backdrop {
display: block;
position: fixed;
inset: 58px 0 32px 96px;
z-index: 19;
border: 0;
background: rgba(29, 33, 41, 0.38);
padding: 0;
}
}
@media (max-width: 767px) {
.topbar {
grid-template-columns: minmax(0, 1fr) auto;
@@ -2223,6 +2284,17 @@
display: none;
}
.topbar .global-search.mobile-open {
display: grid;
position: fixed;
top: 58px;
right: var(--space-3);
left: var(--space-3);
z-index: 30;
background: var(--color-panel);
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.22);
}
.topbar-actions {
gap: var(--space-1);
}
@@ -2231,7 +2303,8 @@
display: none;
}
.mobile-nav-button {
.mobile-nav-button,
.mobile-search-button {
display: inline-flex;
min-width: 40px;
min-height: 40px;

View File

@@ -62,7 +62,8 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
body: requestBody as BodyInit | undefined,
signal: options.signal,
})
} catch {
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
}

View File

@@ -124,7 +124,7 @@ export type SourceDTO = {
projectId: string
kind: string
title: string
filePath: string
storageKey: string
createdAt: string
updatedAt: string
}
@@ -184,12 +184,12 @@ export type CreateProjectTagInput = {
name: string
}
export async function fetchProjects(session: ApiSession) {
return apiRequest<ProjectDTO[]>('/api/v1/projects', { token: session.token })
export async function fetchProjects(session: ApiSession, signal?: AbortSignal) {
return apiRequest<ProjectDTO[]>('/api/v1/projects', { token: session.token, signal })
}
export async function fetchProjectWorkspace(session: ApiSession, projectId: string) {
return apiRequest<ProjectWorkspaceDTO>(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
export async function fetchProjectWorkspace(session: ApiSession, projectId: string, signal?: AbortSignal) {
return apiRequest<ProjectWorkspaceDTO>(`/api/v1/projects/${projectId}/workspace`, { token: session.token, signal })
}
export async function fetchProjectTags(session: ApiSession, projectId: string) {

View File

@@ -1,10 +1,10 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ConfigProvider, Message, Spin } from '@arco-design/web-react'
import '@arco-design/web-react/dist/css/arco.css'
import '../App.css'
import { ApiError, login, setApiBaseUrl, type ApiSession } from '../api/client'
import { createAISession, listAISessions, type CreateAISessionInput } from '../api/ai'
import { analyzeInboxItem, confirmInboxItem } from '../api/inbox'
import { analyzeInboxItem, captureProjectInbox, confirmInboxItem, type CaptureInboxInput } from '../api/inbox'
import { mapWorkspace } from '../api/mappers'
import {
createCronPlan,
@@ -21,11 +21,13 @@ import type { SearchResultDTO } from '../api/search'
import { LoginPage } from '../pages/login'
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
import { SearchResultPreview } from '../pages/projects/search-result-preview'
import { WorkbenchItemPreview } from '../pages/projects/workbench-item-preview'
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
import { ProjectPage } from '../pages/workspace-home'
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
import { useWorkbenchSearch } from './use-workbench-search'
import { createWorkspaceRefreshGate, type WorkspaceRefreshGate } from './workspace-refresh-gate'
function App() {
const [screen, setScreen] = useState<Screen>('login')
@@ -37,12 +39,17 @@ function App() {
const activeProjectIDRef = useRef('')
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
const [, setSelectedItem] = useState('探索采集')
const [selectedItem, setSelectedItem] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
const workspaceSearch = useWorkbenchSearch(session)
const workspaceRefreshGate = useRef<WorkspaceRefreshGate | null>(null)
const actionInFlight = useRef(false)
if (!workspaceRefreshGate.current) workspaceRefreshGate.current = createWorkspaceRefreshGate()
useEffect(() => () => workspaceRefreshGate.current?.invalidate(), [])
const handleListAISessions = useCallback((projectId: string, signal?: AbortSignal) => {
if (!session) return Promise.reject(new Error('未登录'))
@@ -64,11 +71,13 @@ function App() {
}
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) {
const request = workspaceRefreshGate.current!.begin()
const projectIDWhenStarted = activeProjectIDRef.current
const backendProjects = await fetchProjects(nextSession)
const backendProjects = await fetchProjects(nextSession, request.signal)
const backendWorkspaces = await Promise.all(
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id).then((workspace) => mapWorkspace(workspace, index))),
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id, request.signal).then((workspace) => mapWorkspace(workspace, index))),
)
if (!request.isCurrent()) throw staleWorkspaceRefreshError()
setWorkspaces(backendWorkspaces)
const latestProjectID = activeProjectIDRef.current
const requestedProjectID = latestProjectID !== projectIDWhenStarted
@@ -79,6 +88,20 @@ function App() {
return backendWorkspaces
}
async function refreshProjectWorkspace(nextSession: ApiSession, projectID: string) {
const request = workspaceRefreshGate.current!.begin()
const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectID)
const payload = await fetchProjectWorkspace(nextSession, projectID, request.signal)
if (!request.isCurrent()) throw staleWorkspaceRefreshError()
const refreshed = mapWorkspace(payload, workspaceIndex < 0 ? 0 : workspaceIndex)
setWorkspaces((current) => {
const existingIndex = current.findIndex((workspace) => workspace.project.id === projectID)
if (existingIndex < 0) return [...current, refreshed]
return current.map((workspace, index) => index === existingIndex ? refreshed : workspace)
})
return refreshed
}
async function handleLogin(input: { server: string; email: string; password: string }) {
setLoading(true)
try {
@@ -103,6 +126,8 @@ function App() {
}
async function runAction(action: () => Promise<void>, success: string) {
if (actionInFlight.current) throw new Error('操作正在进行,请勿重复提交')
actionInFlight.current = true
setActionLoading(true)
try {
await action()
@@ -110,7 +135,9 @@ function App() {
Message.success(success)
} catch (error) {
Message.error(error instanceof Error ? error.message : '操作失败')
throw error
} finally {
actionInFlight.current = false
setActionLoading(false)
}
}
@@ -130,12 +157,12 @@ function App() {
return trimmed === '' ? undefined : trimmed
}
function handleCreateProject(draft: ProjectDraft) {
async function handleCreateProject(draft: ProjectDraft) {
if (!draft.name.trim()) {
Message.warning('请输入项目名称')
return
}
void runAction(async () => {
return runAction(async () => {
const created = await createProject(requireSession(), {
name: draft.name.trim(),
identifier: draft.identifier.trim(),
@@ -153,12 +180,12 @@ function App() {
}, '项目已创建')
}
function handleCreateTask(draft: TaskDraft) {
async function handleCreateTask(draft: TaskDraft) {
if (!draft.title.trim()) {
Message.warning('请输入任务标题')
return
}
void runAction(async () => {
return runAction(async () => {
await createTask(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
description: draft.description.trim(),
@@ -170,13 +197,13 @@ function App() {
}, '任务已创建')
}
function handleUploadSource(draft: SourceDraft) {
async function handleUploadSource(draft: SourceDraft) {
if (!draft.file) {
Message.warning('请选择文件')
return
}
const file = draft.file
void runAction(async () => {
return runAction(async () => {
await uploadSource(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
file,
@@ -186,7 +213,7 @@ function App() {
}, '文件已上传')
}
function handleCreateCronPlan(draft: CronDraft) {
async function handleCreateCronPlan(draft: CronDraft) {
if (!draft.title.trim()) {
Message.warning('请输入计划名称')
return
@@ -195,7 +222,7 @@ function App() {
Message.warning('请输入 Cron 表达式')
return
}
void runAction(async () => {
return runAction(async () => {
await createCronPlan(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
schedule: draft.schedule.trim(),
@@ -207,13 +234,13 @@ function App() {
}, '计划任务已创建')
}
function handleCreateProjectTag(name: string) {
async function handleCreateProjectTag(name: string) {
const trimmedName = name.trim()
if (!trimmedName) {
Message.warning('请输入标签名称')
return
}
void runAction(async () => {
return runAction(async () => {
await createProjectTag(requireSession(), requireActiveProject(), { name: trimmedName })
await refreshAfterAction()
}, '标签已创建')
@@ -227,7 +254,7 @@ function App() {
}
function handleUpdateWorkspaceTask(update: WorkspaceTaskUpdate) {
void runAction(async () => {
return runAction(async () => {
await updateTask(requireSession(), update.originalProjectId, update.taskId, {
title: update.title,
description: update.summary,
@@ -257,6 +284,13 @@ function App() {
return response.suggestions
}
async function handleCaptureInbox(projectId: string, input: CaptureInboxInput) {
const currentSession = requireSession()
const item = await captureProjectInbox(currentSession, projectId, input)
await refreshProjectWorkspace(currentSession, projectId)
return item.id
}
async function handleConfirmInbox(inboxId: string, suggestionIds: string[]) {
const confirmationProjectID = activeProjectIDRef.current
let response
@@ -264,21 +298,15 @@ function App() {
response = await confirmInboxItem(requireSession(), inboxId, suggestionIds)
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 409) throw error
if (activeProjectIDRef.current !== confirmationProjectID) {
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新核对当前工作区')
}
try {
await refreshAfterAction()
await refreshProjectWorkspace(requireSession(), confirmationProjectID)
} catch {
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入项目核对')
throw new ApiError(409, 'conflict', '确认状态发生冲突,请重新进入项目核对')
}
throw new ApiError(409, 'conflict', '确认状态发生冲突,工作区已刷新,请重新核对')
}
if (activeProjectIDRef.current !== confirmationProjectID) {
return { createdCount: response.createdCount }
throw new ApiError(409, 'conflict', '确认状态发生冲突,原项目已在后台刷新,请重新核对')
}
try {
await refreshAfterAction()
await refreshProjectWorkspace(requireSession(), confirmationProjectID)
return { createdCount: response.createdCount }
} catch {
return {
@@ -356,6 +384,7 @@ function App() {
onSearch={() => void workspaceSearch.onSearch()}
onSelectSearchResult={handleSelectSearchResult}
onAnalyzeInbox={handleAnalyzeInbox}
onCaptureInbox={handleCaptureInbox}
onConfirmInbox={handleConfirmInbox}
onListAISessions={handleListAISessions}
onCreateAISession={handleCreateAISession}
@@ -374,6 +403,7 @@ function App() {
tagOptions={activeTagOptions}
/>
<SearchResultPreview result={searchResultPreview} onClose={() => setSearchResultPreview(null)} />
<WorkbenchItemPreview title={selectedItem} onClose={() => setSelectedItem(null)} />
</main>
</ConfigProvider>
)
@@ -386,6 +416,10 @@ function searchResultTarget(type: string): { channel: ChannelKey; openTask: bool
return null
}
function staleWorkspaceRefreshError() {
return new DOMException('workspace refresh superseded', 'AbortError')
}
function isAuthorizedExternalPreview(result: SearchResultDTO) {
return (
(result.type === 'task' || result.type === 'note')

View File

@@ -0,0 +1,29 @@
export type WorkspaceRefreshTicket = {
signal: AbortSignal
isCurrent: () => boolean
}
export function createWorkspaceRefreshGate() {
let generation = 0
let controller: AbortController | null = null
return {
begin(): WorkspaceRefreshTicket {
generation += 1
controller?.abort()
controller = new AbortController()
const requestGeneration = generation
return {
signal: controller.signal,
isCurrent: () => requestGeneration === generation,
}
},
invalidate() {
generation += 1
controller?.abort()
controller = null
},
}
}
export type WorkspaceRefreshGate = ReturnType<typeof createWorkspaceRefreshGate>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Button, Card, Form, Input, Space, Typography } from '@arco-design/web-react'
import { Button, Card, Checkbox, Form, Input, Space, Typography } from '@arco-design/web-react'
import {
IconCheckCircleFill,
IconCloseCircleFill,
@@ -11,9 +11,14 @@ import { normalizeBaseUrl } from '../api/client'
const { Title, Text } = Typography
type ConnectionStatus = 'checking' | 'online' | 'offline'
const REMEMBERED_SERVER_KEY = 'senlinai.server'
export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) {
const [server, setServer] = useState('http://localhost:9150')
const [server, setServer] = useState(() => {
if (typeof window === 'undefined') return 'http://localhost:9150'
return window.localStorage.getItem(REMEMBERED_SERVER_KEY) || 'http://localhost:9150'
})
const [rememberServer, setRememberServer] = useState(() => typeof window !== 'undefined' && window.localStorage.getItem(REMEMBERED_SERVER_KEY) !== null)
const [email, setEmail] = useState('demo@senlin.ai')
const [password, setPassword] = useState('password123')
const [status, setStatus] = useState<ConnectionStatus>('checking')
@@ -42,8 +47,6 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai
<Text type="secondary"> · · AI </Text>
<Space className="login-footer-links" size={24}>
<Text type="secondary">v1.0.0</Text>
<Text type="secondary"></Text>
<Text type="secondary"></Text>
</Space>
</div>
@@ -63,13 +66,13 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai
<Input.Password value={password} onChange={setPassword} placeholder="请输入密码" />
</Form.Item>
<div className="login-row">
<Space size={8}>
<span className="check-dot" />
<Text></Text>
</Space>
<Button type="text" size="mini">?</Button>
<Checkbox checked={rememberServer} onChange={setRememberServer}></Checkbox>
</div>
<Button type="primary" long size="large" onClick={() => onLogin({ server, email, password })}>
<Button type="primary" long size="large" onClick={() => {
if (rememberServer) window.localStorage.setItem(REMEMBERED_SERVER_KEY, server)
else window.localStorage.removeItem(REMEMBERED_SERVER_KEY)
onLogin({ server, email, password })
}}>
</Button>
</Form>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { Alert, Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
const { Text } = Typography
const { TextArea } = Input
@@ -45,19 +45,30 @@ export function ProjectActionModals({
activeModal: ProjectActionModal
loading: boolean
onClose: () => void
onCreateProject: (draft: ProjectDraft) => void
onCreateTask: (draft: TaskDraft) => void
onUploadSource: (draft: SourceDraft) => void
onCreateCronPlan: (draft: CronDraft) => void
onCreateProject: (draft: ProjectDraft) => Promise<void>
onCreateTask: (draft: TaskDraft) => Promise<void>
onUploadSource: (draft: SourceDraft) => Promise<void>
onCreateCronPlan: (draft: CronDraft) => Promise<void>
tagOptions: string[]
}) {
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', tag: '' })
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
const [submitError, setSubmitError] = useState('')
async function submit(action: () => Promise<void>) {
setSubmitError('')
try {
await action()
} catch (error) {
setSubmitError(error instanceof Error ? error.message : '操作失败,请稍后重试')
}
}
useEffect(() => {
if (activeModal === null) {
setSubmitError('')
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
setTask({ title: '', description: '', tag: '' })
setSource({ title: '', file: null })
@@ -72,10 +83,13 @@ export function ProjectActionModals({
title="新建项目"
visible={activeModal === 'project'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateProject(project)}
maskClosable={!loading}
cancelButtonProps={{ disabled: loading }}
onCancel={() => { if (!loading) onClose() }}
onOk={() => void submit(() => onCreateProject(project))}
>
<Space direction="vertical" size={12} className="action-form">
{submitError && <Alert type="error" content={submitError} />}
<label>
<Text></Text>
<Input placeholder="例如:项目 A3" value={project.name} onChange={(name) => setProject((draft) => ({ ...draft, name }))} />
@@ -104,10 +118,13 @@ export function ProjectActionModals({
title="新建任务"
visible={activeModal === 'task'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateTask(task)}
maskClosable={!loading}
cancelButtonProps={{ disabled: loading }}
onCancel={() => { if (!loading) onClose() }}
onOk={() => void submit(() => onCreateTask(task))}
>
<Space direction="vertical" size={12} className="action-form">
{submitError && <Alert type="error" content={submitError} />}
<label>
<Text></Text>
<Input placeholder="要完成什么?" value={task.title} onChange={(title) => setTask((draft) => ({ ...draft, title }))} />
@@ -137,10 +154,13 @@ export function ProjectActionModals({
title="上传文件"
visible={activeModal === 'source'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onUploadSource(source)}
maskClosable={!loading}
cancelButtonProps={{ disabled: loading }}
onCancel={() => { if (!loading) onClose() }}
onOk={() => void submit(() => onUploadSource(source))}
>
<Space direction="vertical" size={12} className="action-form">
{submitError && <Alert type="error" content={submitError} />}
<label>
<Text></Text>
<Input placeholder="默认使用文件名" value={source.title} onChange={(title) => setSource((draft) => ({ ...draft, title }))} />
@@ -161,10 +181,13 @@ export function ProjectActionModals({
title="新建计划任务"
visible={activeModal === 'cron'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onCreateCronPlan(cron)}
maskClosable={!loading}
cancelButtonProps={{ disabled: loading }}
onCancel={() => { if (!loading) onClose() }}
onOk={() => void submit(() => onCreateCronPlan(cron))}
>
<Space direction="vertical" size={12} className="action-form">
{submitError && <Alert type="error" content={submitError} />}
<label>
<Text></Text>
<Input placeholder="例如:每日 Inbox 整理" value={cron.title} onChange={(title) => setCron((draft) => ({ ...draft, title }))} />

View File

@@ -7,8 +7,11 @@ import { ProjectOverview } from './project-overview'
import type { ProjectTaskUpdate } from './project-task-edit-modal'
import { ProjectTasks } from './project-tasks'
import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types'
import type { InboxSuggestionDTO } from '../../api/inbox'
import type { CaptureInboxInput, InboxSuggestionDTO } from '../../api/inbox'
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
import { Card, Empty, Typography } from '@arco-design/web-react'
const { Text } = Typography
export function ProjectChannelPage({
activeChannel,
@@ -23,6 +26,7 @@ export function ProjectChannelPage({
onCreateProjectTag,
onUpdateTask,
onAnalyzeInbox,
onCaptureInbox,
onConfirmInbox,
onListAISessions,
onCreateAISession,
@@ -36,16 +40,28 @@ export function ProjectChannelPage({
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
onCreateProjectTag: (name: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void
onCreateProjectTag: (name: string) => Promise<void>
onUpdateTask: (update: ProjectTaskUpdate) => Promise<void>
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onCaptureInbox: (projectId: string, input: CaptureInboxInput) => Promise<string>
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
}) {
if (activeChannel.startsWith('custom:')) {
return (
<div className="project-channel-page project-unavailable-page overview-page">
<Card className="queue-section" bordered>
<Empty description="自定义链接频道暂未开放" />
<Text type="secondary">MVP </Text>
</Card>
</div>
)
}
switch (activeChannel) {
case 'inbox':
return <ProjectInbox key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onAnalyze={onAnalyzeInbox} onConfirm={onConfirmInbox} />
return <ProjectInbox key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onCapture={onCaptureInbox} onAnalyze={onAnalyzeInbox} onConfirm={onConfirmInbox} />
case 'tasks':
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
case 'ai':

View File

@@ -1,5 +1,5 @@
import { Button, Card, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import { IconClockCircle, IconPlus, IconThunderbolt } from '@arco-design/web-react/icon'
import { Button, Card, Empty, Space, Tag, Typography } from '@arco-design/web-react'
import { IconClockCircle, IconPlus } from '@arco-design/web-react/icon'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
@@ -41,18 +41,18 @@ export function ProjectCron({ activeWorkspace, onSelectItem, onCreateCronPlan }:
<span className="row-title"><IconClockCircle /> {job.name}</span>
<Tag color={job.enabled ? 'green' : 'gray'}>{job.status}</Tag>
<span>{job.expr}</span>
<span>{job.lastRun}</span>
<span>{job.lastRun || '暂无执行记录'}</span>
<span>{job.nextRun}</span>
<span>{job.owner}</span>
<Switch size="small" checked={job.enabled} />
</div>
))}
{cronJobs.length === 0 && <Empty description="暂无计划任务" />}
</Card>
<Card className="queue-section cron-summary" bordered>
<Space>
<Tag color="arcoblue"><IconThunderbolt /></Tag>
<Text> 12:00</Text>
<Tag color="arcoblue"></Tag>
<Text>MVP </Text>
</Space>
</Card>
</div>

View File

@@ -1,14 +1,16 @@
import { useMemo, useRef, useState } from 'react'
import { Alert, Button, Card, Checkbox, Empty, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircle, IconRobot } from '@arco-design/web-react/icon'
import { Alert, Button, Card, Checkbox, Empty, Input, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircle, IconPlus, IconRobot } from '@arco-design/web-react/icon'
import { ApiError } from '../../api/client'
import type { InboxSuggestionDTO } from '../../api/inbox'
import type { CaptureInboxInput, InboxSuggestionDTO } from '../../api/inbox'
import type { InboxConfirmationOutcome, InboxItem, ProjectWorkspace } from './project-types'
const { Title, Text, Paragraph } = Typography
const { TextArea } = Input
type ProjectInboxProps = {
activeWorkspace: ProjectWorkspace
onCapture: (projectId: string, input: CaptureInboxInput) => Promise<string>
onAnalyze: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onConfirm: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
}
@@ -18,7 +20,7 @@ type UncertainConfirmation = {
suggestionIds: string[]
}
export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectInboxProps) {
export function ProjectInbox({ activeWorkspace, onCapture, onAnalyze, onConfirm }: ProjectInboxProps) {
const [selectedItemId, setSelectedItemId] = useState(activeWorkspace.inbox[0]?.id ?? '')
const [draftSuggestions, setDraftSuggestions] = useState<InboxSuggestionDTO[]>([])
const [selectedSuggestionIds, setSelectedSuggestionIds] = useState<string[]>([])
@@ -29,12 +31,42 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
const [refreshWarning, setRefreshWarning] = useState('')
const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState<string[]>([])
const [confirmationUncertain, setConfirmationUncertain] = useState<UncertainConfirmation | null>(null)
const [captureDraft, setCaptureDraft] = useState({ title: '', body: '' })
const [capturing, setCapturing] = useState(false)
const [captureError, setCaptureError] = useState('')
const [captureSuccess, setCaptureSuccess] = useState('')
const analysisGeneration = useRef(0)
const selectedItem = useMemo(
() => activeWorkspace.inbox.find((item) => item.id === selectedItemId) ?? activeWorkspace.inbox[0],
[activeWorkspace.inbox, selectedItemId],
)
async function captureItem() {
const title = captureDraft.title.trim()
const body = captureDraft.body.trim()
if (!title && !body) {
setCaptureError('请输入标题或收集内容')
return
}
setCapturing(true)
setCaptureError('')
setCaptureSuccess('')
try {
const itemId = await onCapture(activeWorkspace.project.id, {
sourceType: 'manual',
title: title || body.slice(0, 40),
body: body || title,
})
setSelectedItemId(itemId)
setCaptureDraft({ title: '', body: '' })
setCaptureSuccess('已收集到 Inbox可继续分析并确认创建')
} catch (reason) {
setCaptureError(reason instanceof Error ? reason.message : '收集失败,请稍后重试')
} finally {
setCapturing(false)
}
}
function selectItem(item: InboxItem) {
if (confirmationUncertain) return
analysisGeneration.current += 1
@@ -112,6 +144,35 @@ export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectI
</div>
</div>
<Card className="queue-section inbox-capture-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Text type="secondary"> Inbox</Text>
</div>
<Space direction="vertical" size={10} className="inbox-capture-form">
<Input
aria-label="Inbox 标题"
placeholder="标题(可选)"
value={captureDraft.title}
disabled={capturing}
onChange={(title) => setCaptureDraft((draft) => ({ ...draft, title }))}
/>
<TextArea
aria-label="Inbox 收集内容"
placeholder="粘贴想法、待办或资料内容"
rows={3}
value={captureDraft.body}
disabled={capturing}
onChange={(body) => setCaptureDraft((draft) => ({ ...draft, body }))}
/>
{captureError && <Alert type="error" content={captureError} />}
{captureSuccess && <Alert type="success" content={captureSuccess} />}
<Button type="primary" icon={<IconPlus />} loading={capturing} disabled={capturing} onClick={() => void captureItem()}>
Inbox
</Button>
</Space>
</Card>
{activeWorkspace.inbox.length === 0 ? (
<Card className="queue-section" bordered>
<Empty description="当前没有待处理的 Inbox 内容" />

View File

@@ -1,5 +1,5 @@
import { Button, Card, Space, Typography } from '@arco-design/web-react'
import { IconPlus, IconSearch } from '@arco-design/web-react/icon'
import { IconPlus } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid'
import type { ProjectWorkspace } from './project-types'
@@ -16,7 +16,6 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
<Text type="secondary">{project.name} 线</Text>
</div>
<Space>
<Button icon={<IconSearch />}></Button>
<Button type="primary" icon={<IconPlus />} onClick={onUploadSource}>/</Button>
</Space>
</div>
@@ -24,7 +23,6 @@ export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }:
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
</Card>

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircleFill, IconClockCircle, IconFile, IconLink } from '@arco-design/web-react/icon'
import { Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
import { IconCheckCircleFill, IconClockCircle, IconFile } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid'
import { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal'
import type { ProjectWorkspace, TaskItem } from './project-types'
@@ -15,13 +15,12 @@ export function ProjectOverview({
}: {
activeWorkspace: ProjectWorkspace
onSelectItem: (title: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void
onUpdateTask: (update: ProjectTaskUpdate) => Promise<void>
}) {
const { project, tasks, notes, cronJobs } = activeWorkspace
const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks])
const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks])
const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
const shareURL = useMemo(() => projectShareURL(project.identifier || project.id), [project.id, project.identifier])
const metrics = useMemo(() => [
{ title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
{ title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
@@ -40,8 +39,8 @@ export function ProjectOverview({
</div>
</div>
<div className="project-overview-share">
<Text> URL</Text>
<Button icon={<IconLink />} onClick={() => onSelectItem(shareURL)}>{shareURL}</Button>
<Text></Text>
<Tag color="arcoblue">{project.identifier || '未设置'}</Tag>
</div>
</section>
@@ -69,10 +68,7 @@ export function ProjectOverview({
task={editingTask}
workspace={activeWorkspace}
onClose={() => setEditingTask(null)}
onSubmit={(update) => {
onUpdateTask(update)
setEditingTask(null)
}}
onSubmit={onUpdateTask}
/>
</div>
)
@@ -95,7 +91,6 @@ function TaskCardSection({
<Card className="queue-section overview-task-section" bordered>
<div className="section-header">
<Title heading={6}>{title}</Title>
<Button type="text" size="mini"></Button>
</div>
{tasks.length === 0 ? (
<Text type="secondary">{emptyText}</Text>
@@ -126,19 +121,12 @@ function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>{notes.length}</Title>
<Button type="text" size="mini"></Button>
</div>
<ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
</Card>
)
}
function projectShareURL(identifier: string) {
const slug = encodeURIComponent(identifier.trim() || 'project')
const origin = typeof window === 'undefined' ? 'https://senlinai.local' : window.location.origin
return `${origin}/share/projects/${slug}`
}
function projectHeroBackground(background: string, fallback: string) {
const value = background.trim() || fallback
if (value.startsWith('http')) {

View File

@@ -50,20 +50,22 @@ export function ProjectRail({
aria-label="探索"
title="探索"
/>
<Space className="project-stack" direction="vertical" size={12}>
{projects.map((project) => (
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
<button
className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'}
onClick={() => selectAndClose(() => onSelectProject(project))}
style={{ '--project-color': project.color } as CSSProperties}
title={project.name}
>
{project.short}
</button>
</Badge>
))}
</Space>
<div className="project-stack-scroll">
<Space className="project-stack" direction="vertical" size={12}>
{projects.map((project) => (
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
<button
className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'}
onClick={() => selectAndClose(() => onSelectProject(project))}
style={{ '--project-color': project.color } as CSSProperties}
title={project.name}
>
{project.short}
</button>
</Badge>
))}
</Space>
</div>
<Button className="create-project" aria-label="新建项目" icon={<IconPlus />} title="新建项目" onClick={() => selectAndClose(onCreateProject)} />
</Sider>
)

View File

@@ -1,4 +1,4 @@
import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { Alert, Input, Modal, Select, Space, Switch, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import type { ProjectWorkspace, TaskItem } from './project-types'
@@ -29,13 +29,16 @@ export function ProjectTaskEditModal({
task: TaskItem | null
workspace: ProjectWorkspace
onClose: () => void
onSubmit: (update: ProjectTaskUpdate) => void
onSubmit: (update: ProjectTaskUpdate) => Promise<void>
}) {
const tagOptions = useMemo(() => workspace.tags.filter((tag) => tag !== 'all' && tag !== '全部'), [workspace.tags])
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', tag: '', completed: false })
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
useEffect(() => {
if (!task) return
setSaveError('')
setDraft({
title: task.title,
summary: task.summary,
@@ -44,24 +47,39 @@ export function ProjectTaskEditModal({
})
}, [task])
async function saveTask() {
if (!task || saving) return
setSaving(true)
setSaveError('')
try {
await onSubmit({
taskId: task.id,
title: draft.title.trim() || task.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
onClose()
} catch (error) {
setSaveError(error instanceof Error ? error.message : '任务保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
return (
<Modal
className="action-modal"
title="编辑计划"
visible={Boolean(task)}
onCancel={onClose}
onOk={() => {
if (!task) return
onSubmit({
taskId: task.id,
title: draft.title.trim() || task.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
}}
confirmLoading={saving}
maskClosable={!saving}
cancelButtonProps={{ disabled: saving }}
onCancel={() => { if (!saving) onClose() }}
onOk={() => void saveTask()}
>
<Space direction="vertical" size={12} className="action-form">
{saveError && <Alert type="error" content={saveError} />}
<label>
<Text></Text>
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Button, Card, Descriptions, Input, Modal, Progress, Space, Tag, Typography } from '@arco-design/web-react'
import { Alert, Button, Card, Descriptions, Input, Modal, Progress, Space, Tag, Typography } from '@arco-design/web-react'
import { IconArrowLeft, IconCalendar, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconPlus } from '@arco-design/web-react/icon'
import { ProjectTaskEditModal, type ProjectTaskUpdate } from './project-task-edit-modal'
import type { ProjectWorkspace, TaskItem } from './project-types'
@@ -21,8 +21,8 @@ export function ProjectTasks({
onCloseTask: () => void
onSelectItem: (title: string) => void
onCreateTask: () => void
onCreateProjectTag: (name: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void
onCreateProjectTag: (name: string) => Promise<void>
onUpdateTask: (update: ProjectTaskUpdate) => Promise<void>
}) {
const { tasks, project, tags } = activeWorkspace
const activeTask = tasks.find((task) => task.id === activeTaskID)
@@ -32,17 +32,27 @@ export function ProjectTasks({
const [tagModalOpen, setTagModalOpen] = useState(false)
const [tagName, setTagName] = useState('')
const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
const [tagSaving, setTagSaving] = useState(false)
const [tagError, setTagError] = useState('')
if (activeTask) {
return <TaskDetail activeWorkspace={activeWorkspace} task={activeTask} onBack={onCloseTask} />
}
function submitTag() {
async function submitTag() {
const nextTag = tagName.trim()
if (!nextTag) return
onCreateProjectTag(nextTag)
setTagName('')
setTagModalOpen(false)
if (!nextTag || tagSaving) return
setTagSaving(true)
setTagError('')
try {
await onCreateProjectTag(nextTag)
setTagName('')
setTagModalOpen(false)
} catch (error) {
setTagError(error instanceof Error ? error.message : '标签创建失败,请稍后重试')
} finally {
setTagSaving(false)
}
}
return (
@@ -111,16 +121,22 @@ export function ProjectTasks({
className="action-modal"
title="新建标签"
visible={tagModalOpen}
confirmLoading={tagSaving}
maskClosable={!tagSaving}
cancelButtonProps={{ disabled: tagSaving }}
onCancel={() => {
if (tagSaving) return
setTagModalOpen(false)
setTagName('')
setTagError('')
}}
onOk={submitTag}
onOk={() => void submitTag()}
>
<Space direction="vertical" size={12} className="action-form">
{tagError && <Alert type="error" content={tagError} />}
<label>
<Text></Text>
<Input autoFocus placeholder="例如:设计、客户、重要" value={tagName} onChange={setTagName} onPressEnter={submitTag} />
<Input autoFocus placeholder="例如:设计、客户、重要" value={tagName} onChange={setTagName} onPressEnter={() => void submitTag()} />
</label>
</Space>
</Modal>
@@ -128,10 +144,7 @@ export function ProjectTasks({
task={editingTask}
workspace={activeWorkspace}
onClose={() => setEditingTask(null)}
onSubmit={(update) => {
onUpdateTask(update)
setEditingTask(null)
}}
onSubmit={onUpdateTask}
/>
</div>
)

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Button, Dropdown, Input, Layout, List, Space, Typography } from '@arco-design/web-react'
import { IconApps, IconMenuUnfold, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon'
import { IconApps, IconClose, IconMenuUnfold, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon'
import type { SearchResultDTO } from '../../api/search'
import type { Theme } from './project-types'
@@ -35,6 +35,7 @@ export function ProjectTopbar({
onSelectSearchResult: (result: SearchResultDTO) => void
}) {
const [popupVisible, setPopupVisible] = useState(false)
const [mobileSearchOpen, setMobileSearchOpen] = useState(false)
useEffect(() => {
if (!loading && searched) setPopupVisible(true)
@@ -42,6 +43,7 @@ export function ProjectTopbar({
const submitSearch = () => {
setPopupVisible(false)
setMobileSearchOpen(false)
onSearch()
}
@@ -59,6 +61,7 @@ export function ProjectTopbar({
className="search-result-item"
onClick={() => {
setPopupVisible(false)
setMobileSearchOpen(false)
onSelectSearchResult(result)
}}
>
@@ -87,7 +90,7 @@ export function ProjectTopbar({
position="bl"
onVisibleChange={(visible) => setPopupVisible(visible && searched)}
>
<div className="global-search" role="search">
<div className={mobileSearchOpen ? 'global-search mobile-open' : 'global-search'} role="search">
<Input
size="large"
prefix={<IconSearch />}
@@ -103,9 +106,15 @@ export function ProjectTopbar({
</div>
</Dropdown>
<Space className="topbar-actions">
<Button className="mobile-nav-button" aria-label="打开项目导航" icon={<IconApps />} onClick={onOpenProjects} />
<Button
className="mobile-search-button"
aria-label={mobileSearchOpen ? '关闭全局搜索' : '打开全局搜索'}
icon={mobileSearchOpen ? <IconClose /> : <IconSearch />}
onClick={() => setMobileSearchOpen((open) => !open)}
/>
<Button className="mobile-nav-button project-nav-button" aria-label="打开项目导航" icon={<IconApps />} onClick={onOpenProjects} />
{showChannels && (
<Button className="mobile-nav-button" aria-label="打开频道导航" icon={<IconMenuUnfold />} onClick={onOpenChannels} />
<Button className="mobile-nav-button channel-nav-button" aria-label="打开频道导航" icon={<IconMenuUnfold />} onClick={onOpenChannels} />
)}
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
</Space>

View File

@@ -0,0 +1,20 @@
import { Modal, Space, Typography } from '@arco-design/web-react'
const { Text, Title } = Typography
export function WorkbenchItemPreview({ title, onClose }: { title: string | null; onClose: () => void }) {
return (
<Modal
className="item-preview-modal"
title="只读预览"
visible={Boolean(title)}
footer={null}
onCancel={onClose}
>
<Space direction="vertical" size={12}>
<Title heading={5}>{title}</Title>
<Text type="secondary"></Text>
</Space>
</Modal>
)
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Card, Empty, Grid, Input, Modal, Select, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import { Alert, Card, Empty, Grid, Input, Modal, Select, Space, Switch, Tag, Typography } from '@arco-design/web-react'
import {
IconCalendar,
IconCheckCircleFill,
@@ -41,7 +41,7 @@ export function WorkspacePage({
}: {
workspaces: ProjectWorkspace[]
onOpenTask: (project: Project, taskID: string) => void
onUpdateTask: (update: WorkspaceTaskUpdate) => void
onUpdateTask: (update: WorkspaceTaskUpdate) => Promise<void>
}) {
const projects = workspaces.map((workspace) => workspace.project)
const projectOptions = useMemo(() => workspaces.map((workspace) => workspace.project), [workspaces])
@@ -55,6 +55,8 @@ export function WorkspacePage({
)
const [editingTask, setEditingTask] = useState<WorkspaceTask | null>(null)
const [draft, setDraft] = useState<TaskDraft>({ title: '', summary: '', projectId: '', tag: '', completed: false })
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
const tagOptions = useMemo(() => {
const selectedWorkspace = workspaces.find((workspace) => workspace.project.id === draft.projectId)
return selectedWorkspace?.tags.filter((tag) => tag !== 'all' && tag !== '全部') ?? []
@@ -62,6 +64,7 @@ export function WorkspacePage({
useEffect(() => {
if (!editingTask) return
setSaveError('')
setDraft({
title: editingTask.title,
summary: editingTask.summary,
@@ -70,6 +73,28 @@ export function WorkspacePage({
completed: editingTask.completed,
})
}, [editingTask])
async function saveTask() {
if (!editingTask || saving) return
setSaving(true)
setSaveError('')
try {
await onUpdateTask({
originalProjectId: editingTask.project.id,
nextProjectId: draft.projectId,
taskId: editingTask.id,
title: draft.title.trim() || editingTask.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
setEditingTask(null)
} catch (error) {
setSaveError(error instanceof Error ? error.message : '任务保存失败,请稍后重试')
} finally {
setSaving(false)
}
}
useEffect(() => {
if (!draft.tag || tagOptions.includes(draft.tag)) return
setDraft((value) => ({ ...value, tag: '' }))
@@ -145,22 +170,14 @@ export function WorkspacePage({
className="action-modal"
title="编辑任务"
visible={Boolean(editingTask)}
onCancel={() => setEditingTask(null)}
onOk={() => {
if (!editingTask) return
onUpdateTask({
originalProjectId: editingTask.project.id,
nextProjectId: draft.projectId,
taskId: editingTask.id,
title: draft.title.trim() || editingTask.title,
summary: draft.summary.trim(),
tag: draft.tag.trim(),
completed: draft.completed,
})
setEditingTask(null)
}}
confirmLoading={saving}
maskClosable={!saving}
cancelButtonProps={{ disabled: saving }}
onCancel={() => { if (!saving) setEditingTask(null) }}
onOk={() => void saveTask()}
>
<Space direction="vertical" size={12} className="action-form">
{saveError && <Alert type="error" content={saveError} />}
<label>
<Text></Text>
<Input value={draft.title} onChange={(title) => setDraft((value) => ({ ...value, title }))} />

View File

@@ -9,7 +9,7 @@ import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-s
import { ProjectStatusbar } from './projects/project-statusbar'
import { ProjectTopbar } from './projects/project-topbar'
import type { SearchResultDTO } from '../api/search'
import type { InboxSuggestionDTO } from '../api/inbox'
import type { CaptureInboxInput, InboxSuggestionDTO } from '../api/inbox'
import type { AISessionDTO, CreateAISessionInput } from '../api/ai'
import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
@@ -45,6 +45,7 @@ export function ProjectPage({
onSearch,
onSelectSearchResult,
onAnalyzeInbox,
onCaptureInbox,
onConfirmInbox,
onListAISessions,
onCreateAISession,
@@ -67,9 +68,9 @@ export function ProjectPage({
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => Promise<void>
onUpdateProject: (update: ProjectSettingsUpdate) => Promise<void>
onCreateProjectTag: (name: string) => void
onCreateProjectTag: (name: string) => Promise<void>
searchQuery: string
searchLoading: boolean
searchSearched: boolean
@@ -78,6 +79,7 @@ export function ProjectPage({
onSearch: () => void
onSelectSearchResult: (result: SearchResultDTO) => void
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
onCaptureInbox: (projectId: string, input: CaptureInboxInput) => Promise<string>
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
@@ -86,7 +88,7 @@ export function ProjectPage({
const [navOpen, setNavOpen] = useState<'projects' | 'channels' | null>(null)
const projects = workspaces.map((workspace) => workspace.project)
const updateActiveProjectTask = (update: ProjectTaskUpdate) => {
onUpdateWorkspaceTask({
return onUpdateWorkspaceTask({
originalProjectId: activeWorkspace.project.id,
nextProjectId: activeWorkspace.project.id,
taskId: update.taskId,
@@ -163,6 +165,7 @@ export function ProjectPage({
onCreateProjectTag={onCreateProjectTag}
onUpdateTask={updateActiveProjectTask}
onAnalyzeInbox={onAnalyzeInbox}
onCaptureInbox={onCaptureInbox}
onConfirmInbox={onConfirmInbox}
onListAISessions={onListAISessions}
onCreateAISession={onCreateAISession}