mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 00:03:09 +00:00
Preserve Jira task source context
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -218,6 +218,7 @@ Done means: reviewers and future agents can understand the vision, implementatio
|
||||
- [x] 2026-06-12: Polished sidebar host headers from card-like rows into quieter sidebar grouping rows while keeping explicit SSH/status/count text; verified with focused sidebar tests, web typecheck, and live Electron screenshot `notes/artifacts/host-context-sidebar-calm-host-headers.png`.
|
||||
- [x] 2026-06-12: Added a narrow `.gitignore` rule for `notes/artifacts/` so verification screenshots stay local while the HTML report can reference them; verified with `git check-ignore`.
|
||||
- [x] 2026-06-12: Added `taskSourceContext` and `workspaceRunContext` to retryable pending worktree creation requests, seeded them from the Composer's selected task source and project-host setup, and verified the pending store preserves both identities with focused worktree tests plus web typecheck.
|
||||
- [x] 2026-06-12: Preserved Jira task detail source context in Tasks open payloads, back/forward history entries, and replay state so a reopened Jira issue does not fall back to the ambient site/host; added a same-issue/different-source-host history regression and verified focused UI/history/feature-interaction tests, oxlint, and full `pnpm run typecheck`.
|
||||
- [x] 2026-06-12: Threaded GitHub Tasks row-level mutations through `TaskSourceContext` for issue state, assignees, reviewer requests/removals, PR merge/auto-merge, and new issue creation/follow-up reads; verified with focused GitHub/TaskPage tests, focused oxlint, `pnpm run typecheck`, and `git diff --check`.
|
||||
- [x] 2026-06-12: Added provider account-scope diagnostics to GitHub/GitLab CLI integration cards so code-host credential checks show whether they belong to the local desktop client or selected remote server, matching the existing Linear/Jira ownership copy; verified with focused integration-card scope tests and oxlint.
|
||||
- [x] 2026-06-12: Extended task-detail navigation state to GitLab and fixed Linear replay so saved `TaskSourceContext` is restored through back/forward and direct detail reopens; GitLab detail history keys now include source scope so identical MRs from different hosts stay distinct. Verified with focused UI/history/static-boundary tests and oxlint.
|
||||
|
||||
@@ -3579,7 +3579,9 @@ export default function TaskPage(): React.JSX.Element {
|
||||
openGitLabWorkItem: undefined,
|
||||
openGitLabSourceContext: undefined,
|
||||
openLinearIssue: undefined,
|
||||
openLinearSourceContext: undefined
|
||||
openLinearSourceContext: undefined,
|
||||
openJiraIssue: undefined,
|
||||
openJiraSourceContext: undefined
|
||||
}
|
||||
}))
|
||||
}, [clearSelectedLinearIssue, setDialogWorkItem])
|
||||
@@ -3600,12 +3602,46 @@ export default function TaskPage(): React.JSX.Element {
|
||||
const selectedJiraIssue = selectedJiraIssueKey
|
||||
? (cachedSelectedJiraIssue ?? selectedJiraIssueFallback)
|
||||
: null
|
||||
const jiraDetailSourceContext = useMemo(() => {
|
||||
if (
|
||||
selectedJiraIssue &&
|
||||
pageData.openJiraSourceContext?.provider === 'jira' &&
|
||||
pageData.openJiraIssue?.key === selectedJiraIssue.key &&
|
||||
pageData.openJiraIssue.siteId === selectedJiraIssue.siteId
|
||||
) {
|
||||
return pageData.openJiraSourceContext
|
||||
}
|
||||
return jiraTaskSourceContext
|
||||
}, [
|
||||
jiraTaskSourceContext,
|
||||
pageData.openJiraIssue,
|
||||
pageData.openJiraSourceContext,
|
||||
selectedJiraIssue
|
||||
])
|
||||
|
||||
const setSelectedJiraIssue = useCallback((issue: JiraIssue | null) => {
|
||||
setSelectedJiraIssueKey(issue?.key ?? null)
|
||||
setSelectedJiraIssueFallback(issue)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedJiraIssue(pageData.openJiraIssue ?? null)
|
||||
}, [pageData.openJiraIssue, setSelectedJiraIssue])
|
||||
|
||||
const openJiraDetailPage = useCallback(
|
||||
(issue: JiraIssue) => {
|
||||
openTaskPage(
|
||||
{
|
||||
taskSource: 'jira',
|
||||
openJiraIssue: issue,
|
||||
openJiraSourceContext: jiraTaskSourceContext
|
||||
},
|
||||
{ recordTasksInteraction: false }
|
||||
)
|
||||
},
|
||||
[jiraTaskSourceContext, openTaskPage]
|
||||
)
|
||||
|
||||
// Linear tab state
|
||||
const [linearMode, setLinearMode] = useState<LinearMode>('issues')
|
||||
const [linearIssues, setLinearIssues] = useState<LinearIssue[]>([])
|
||||
@@ -9070,14 +9106,14 @@ export default function TaskPage(): React.JSX.Element {
|
||||
tabIndex={0}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
data-current={selected ? 'true' : undefined}
|
||||
onClick={() => setSelectedJiraIssue(issue)}
|
||||
onClick={() => openJiraDetailPage(issue)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target !== e.currentTarget) {
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setSelectedJiraIssue(issue)
|
||||
openJiraDetailPage(issue)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@@ -9238,8 +9274,8 @@ export default function TaskPage(): React.JSX.Element {
|
||||
<JiraIssueWorkspace
|
||||
issue={selectedJiraIssue}
|
||||
onUse={handleUseJiraItem}
|
||||
onClose={() => setSelectedJiraIssue(null)}
|
||||
sourceContext={jiraTaskSourceContext}
|
||||
onClose={closeTaskDetailPage}
|
||||
sourceContext={jiraDetailSourceContext}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -587,7 +587,9 @@ setWorktreeNavViewActivator((entry) => {
|
||||
openGitLabWorkItem: undefined,
|
||||
openGitLabSourceContext: undefined,
|
||||
openLinearIssue: undefined,
|
||||
openLinearSourceContext: undefined
|
||||
openLinearSourceContext: undefined,
|
||||
openJiraIssue: undefined,
|
||||
openJiraSourceContext: undefined
|
||||
}
|
||||
}))
|
||||
return
|
||||
@@ -605,7 +607,9 @@ setWorktreeNavViewActivator((entry) => {
|
||||
openGitLabWorkItem: undefined,
|
||||
openGitLabSourceContext: undefined,
|
||||
openLinearIssue: undefined,
|
||||
openLinearSourceContext: undefined
|
||||
openLinearSourceContext: undefined,
|
||||
openJiraIssue: undefined,
|
||||
openJiraSourceContext: undefined
|
||||
}
|
||||
}))
|
||||
return
|
||||
@@ -624,7 +628,29 @@ setWorktreeNavViewActivator((entry) => {
|
||||
openGitLabWorkItem: entry.workItem,
|
||||
openGitLabSourceContext: entry.sourceContext,
|
||||
openLinearIssue: undefined,
|
||||
openLinearSourceContext: undefined
|
||||
openLinearSourceContext: undefined,
|
||||
openJiraIssue: undefined,
|
||||
openJiraSourceContext: undefined
|
||||
}
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (entry.source === 'jira') {
|
||||
useAppStore.setState((state) => ({
|
||||
activeView: 'tasks',
|
||||
githubTaskDrawerWorkItem: null,
|
||||
taskPageData: {
|
||||
...state.taskPageData,
|
||||
taskSource: 'jira',
|
||||
openGitHubWorkItem: undefined,
|
||||
openGitHubSourceContext: undefined,
|
||||
openGitHubInitialTab: undefined,
|
||||
openGitLabWorkItem: undefined,
|
||||
openGitLabSourceContext: undefined,
|
||||
openLinearIssue: undefined,
|
||||
openLinearSourceContext: undefined,
|
||||
openJiraIssue: entry.issue,
|
||||
openJiraSourceContext: entry.sourceContext
|
||||
}
|
||||
}))
|
||||
return
|
||||
@@ -641,7 +667,9 @@ setWorktreeNavViewActivator((entry) => {
|
||||
openGitLabWorkItem: undefined,
|
||||
openGitLabSourceContext: undefined,
|
||||
openLinearIssue: entry.issue,
|
||||
openLinearSourceContext: entry.sourceContext
|
||||
openLinearSourceContext: entry.sourceContext,
|
||||
openJiraIssue: undefined,
|
||||
openJiraSourceContext: undefined
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultUIState } from '../../../../shared/constants'
|
||||
import type {
|
||||
GitHubWorkItem,
|
||||
JiraIssue,
|
||||
LinearIssue,
|
||||
PersistedUIState,
|
||||
TerminalTab,
|
||||
@@ -158,6 +159,24 @@ function makeGitLabWorkItem(overrides: Partial<GitLabWorkItem> = {}): GitLabWork
|
||||
}
|
||||
}
|
||||
|
||||
function makeJiraIssue(overrides: Partial<JiraIssue> = {}): JiraIssue {
|
||||
return {
|
||||
id: 'ORC-1',
|
||||
key: 'ORC-1',
|
||||
title: 'Fix task source context',
|
||||
url: 'https://example.atlassian.net/browse/ORC-1',
|
||||
siteId: 'site-1',
|
||||
siteName: 'Example Jira',
|
||||
project: { id: '10000', key: 'ORC', name: 'Orca', siteId: 'site-1' },
|
||||
issueType: { id: '10001', name: 'Bug' },
|
||||
status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' },
|
||||
labels: [],
|
||||
createdAt: '2026-05-30T00:00:00.000Z',
|
||||
updatedAt: '2026-05-30T00:00:00.000Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makePersistedUI(overrides: Partial<PersistedUIState> = {}): PersistedUIState {
|
||||
return {
|
||||
...getDefaultUIState(),
|
||||
@@ -1627,13 +1646,16 @@ describe('createUISlice page navigation history', () => {
|
||||
store.setState({ recordFeatureInteraction } as Partial<AppState>)
|
||||
const workItem = makeGitHubWorkItem()
|
||||
const linearIssue = makeLinearIssue()
|
||||
const jiraIssue = makeJiraIssue()
|
||||
|
||||
store.getState().openTaskPage({ taskSource: 'github', openGitHubWorkItem: workItem })
|
||||
store.getState().openTaskPage({ taskSource: 'linear', openLinearIssue: linearIssue })
|
||||
store.getState().openTaskPage({ taskSource: 'jira', openJiraIssue: jiraIssue })
|
||||
|
||||
expect(recordFeatureInteraction).toHaveBeenCalledWith('tasks')
|
||||
expect(recordFeatureInteraction).toHaveBeenCalledWith('github-tasks')
|
||||
expect(recordFeatureInteraction).toHaveBeenCalledWith('linear-tasks')
|
||||
expect(recordFeatureInteraction).toHaveBeenCalledWith('jira-tasks')
|
||||
})
|
||||
|
||||
it('preserves GitHub task detail source context in navigation history', () => {
|
||||
@@ -1716,12 +1738,39 @@ describe('createUISlice page navigation history', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves Jira task detail source context in navigation history', () => {
|
||||
const store = createUIStore()
|
||||
const issue = makeJiraIssue()
|
||||
const sourceContext: TaskSourceContext = {
|
||||
kind: 'task-source',
|
||||
provider: 'jira',
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:remote-server',
|
||||
providerIdentity: { provider: 'jira', siteId: 'site-1' },
|
||||
accountLabel: 'Example Jira'
|
||||
}
|
||||
|
||||
store.getState().openTaskPage({
|
||||
taskSource: 'jira',
|
||||
openJiraIssue: issue,
|
||||
openJiraSourceContext: sourceContext
|
||||
})
|
||||
|
||||
expect(store.getState().worktreeNavHistory.at(-1)).toEqual({
|
||||
kind: 'task-detail',
|
||||
source: 'jira',
|
||||
issue,
|
||||
sourceContext
|
||||
})
|
||||
})
|
||||
|
||||
it('can suppress the Tasks surface interaction for in-page provider navigation', () => {
|
||||
const store = createUIStore()
|
||||
const recordFeatureInteraction = vi.fn()
|
||||
store.setState({ recordFeatureInteraction } as Partial<AppState>)
|
||||
const workItem = makeGitHubWorkItem()
|
||||
const linearIssue = makeLinearIssue()
|
||||
const jiraIssue = makeJiraIssue()
|
||||
|
||||
store
|
||||
.getState()
|
||||
@@ -1735,10 +1784,17 @@ describe('createUISlice page navigation history', () => {
|
||||
{ taskSource: 'linear', openLinearIssue: linearIssue },
|
||||
{ recordTasksInteraction: false }
|
||||
)
|
||||
store
|
||||
.getState()
|
||||
.openTaskPage(
|
||||
{ taskSource: 'jira', openJiraIssue: jiraIssue },
|
||||
{ recordTasksInteraction: false }
|
||||
)
|
||||
|
||||
expect(recordFeatureInteraction).not.toHaveBeenCalledWith('tasks')
|
||||
expect(recordFeatureInteraction).toHaveBeenCalledWith('github-tasks')
|
||||
expect(recordFeatureInteraction).toHaveBeenCalledWith('linear-tasks')
|
||||
expect(recordFeatureInteraction).toHaveBeenCalledWith('jira-tasks')
|
||||
})
|
||||
|
||||
it('skips the whole Tasks detail stack on close', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ChangelogData,
|
||||
CustomPet,
|
||||
GitHubWorkItem,
|
||||
JiraIssue,
|
||||
LinearIssue,
|
||||
PersistedTrustedOrcaHooks,
|
||||
PersistedUIState,
|
||||
@@ -610,6 +611,8 @@ export type UISlice = {
|
||||
openGitLabSourceContext?: TaskSourceContext | null
|
||||
openLinearIssue?: LinearIssue
|
||||
openLinearSourceContext?: TaskSourceContext | null
|
||||
openJiraIssue?: JiraIssue
|
||||
openJiraSourceContext?: TaskSourceContext | null
|
||||
}
|
||||
taskResumeState: TaskResumeState | undefined
|
||||
setTaskResumeState: (updates: Partial<TaskResumeState>) => void
|
||||
@@ -1097,6 +1100,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
if (data.openLinearIssue) {
|
||||
get().recordFeatureInteraction?.('linear-tasks')
|
||||
}
|
||||
if (data.openJiraIssue) {
|
||||
get().recordFeatureInteraction?.('jira-tasks')
|
||||
}
|
||||
// Why: record a Tasks visit in the shared back/forward history so the
|
||||
// titlebar Back/Forward buttons can return to Tasks. All task-source
|
||||
// variants (github/linear presets) collapse to a single 'tasks' entry;
|
||||
@@ -1125,7 +1131,14 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
issue: data.openLinearIssue,
|
||||
sourceContext: data.openLinearSourceContext
|
||||
} as const)
|
||||
: null
|
||||
: data.openJiraIssue
|
||||
? ({
|
||||
kind: 'task-detail',
|
||||
source: 'jira',
|
||||
issue: data.openJiraIssue,
|
||||
sourceContext: data.openJiraSourceContext
|
||||
} as const)
|
||||
: null
|
||||
const currentEntry = get().worktreeNavHistory[get().worktreeNavHistoryIndex]
|
||||
const currentIsTaskStack =
|
||||
currentEntry === 'tasks' ||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createStore, type StoreApi } from 'zustand/vanilla'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import type { GitHubWorkItem, Worktree } from '../../../../shared/types'
|
||||
import type { GitHubWorkItem, JiraIssue, Worktree } from '../../../../shared/types'
|
||||
import type { GitLabWorkItem } from '../../../../shared/gitlab-types'
|
||||
import type { TaskSourceContext } from '../../../../shared/task-source-context'
|
||||
import {
|
||||
@@ -79,6 +79,24 @@ function makeGitLabWorkItem(overrides: Partial<GitLabWorkItem> = {}): GitLabWork
|
||||
}
|
||||
}
|
||||
|
||||
function makeJiraIssue(overrides: Partial<JiraIssue> = {}): JiraIssue {
|
||||
return {
|
||||
id: 'ORC-1',
|
||||
key: 'ORC-1',
|
||||
title: 'Fix task source context',
|
||||
url: 'https://example.atlassian.net/browse/ORC-1',
|
||||
siteId: 'site-1',
|
||||
siteName: 'Example Jira',
|
||||
project: { id: '10000', key: 'ORC', name: 'Orca', siteId: 'site-1' },
|
||||
issueType: { id: '10001', name: 'Bug' },
|
||||
status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' },
|
||||
labels: [],
|
||||
createdAt: '2026-05-30T00:00:00.000Z',
|
||||
updatedAt: '2026-05-30T00:00:00.000Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('worktree-nav-history slice: view entries', () => {
|
||||
afterEach(() => {
|
||||
setWorktreeNavActivator(null)
|
||||
@@ -259,4 +277,36 @@ describe('worktree-nav-history slice: view entries', () => {
|
||||
expect(store.getState().worktreeNavHistory).toHaveLength(2)
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps same Jira issue details separate when the source host differs', () => {
|
||||
const store = createHistoryStore(['a'])
|
||||
const issue = makeJiraIssue()
|
||||
const localSource: TaskSourceContext = {
|
||||
kind: 'task-source',
|
||||
provider: 'jira',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
providerIdentity: { provider: 'jira', siteId: 'site-1' }
|
||||
}
|
||||
const remoteSource: TaskSourceContext = {
|
||||
...localSource,
|
||||
hostId: 'runtime:remote-server'
|
||||
}
|
||||
|
||||
store.getState().recordViewVisit({
|
||||
kind: 'task-detail',
|
||||
source: 'jira',
|
||||
issue,
|
||||
sourceContext: localSource
|
||||
})
|
||||
store.getState().recordViewVisit({
|
||||
kind: 'task-detail',
|
||||
source: 'jira',
|
||||
issue,
|
||||
sourceContext: remoteSource
|
||||
})
|
||||
|
||||
expect(store.getState().worktreeNavHistory).toHaveLength(2)
|
||||
expect(store.getState().worktreeNavHistoryIndex).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import { findWorktreeById } from './worktree-helpers'
|
||||
import type { GitHubWorkItem, LinearIssue } from '../../../../shared/types'
|
||||
import type { GitHubWorkItem, JiraIssue, LinearIssue } from '../../../../shared/types'
|
||||
import type { GitLabWorkItem } from '../../../../shared/gitlab-types'
|
||||
import {
|
||||
getTaskSourceCacheScope,
|
||||
@@ -40,6 +40,12 @@ export type WorktreeNavHistoryTaskDetailEntry =
|
||||
workItem: GitLabWorkItem
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
| {
|
||||
kind: 'task-detail'
|
||||
source: 'jira'
|
||||
issue: JiraIssue
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
export type WorktreeNavHistoryViewEntry =
|
||||
| WorktreeNavHistorySimpleViewEntry
|
||||
| WorktreeNavHistoryTaskDetailEntry
|
||||
@@ -112,6 +118,13 @@ function getHistoryEntryKey(entry: WorktreeNavHistoryEntry): string {
|
||||
: 'legacy'
|
||||
return `view:task-detail:gitlab:${sourceScope}:${entry.workItem.repoId}:${entry.workItem.type}:${entry.workItem.number}`
|
||||
}
|
||||
if (entry.source === 'jira') {
|
||||
const sourceScope =
|
||||
entry.sourceContext?.provider === 'jira'
|
||||
? getTaskSourceCacheScope(entry.sourceContext)
|
||||
: 'legacy'
|
||||
return `view:task-detail:jira:${sourceScope}:${entry.issue.siteId ?? 'selected'}:${entry.issue.key}`
|
||||
}
|
||||
const sourceScope =
|
||||
entry.sourceContext?.provider === 'linear'
|
||||
? getTaskSourceCacheScope(entry.sourceContext)
|
||||
|
||||
@@ -14,6 +14,7 @@ export type FeatureInteractionId =
|
||||
| 'github-tasks'
|
||||
| 'gitlab-tasks'
|
||||
| 'linear-tasks'
|
||||
| 'jira-tasks'
|
||||
| 'automations'
|
||||
| 'automation-created'
|
||||
| 'automation-run'
|
||||
@@ -80,6 +81,7 @@ export const FEATURE_INTERACTIONS = [
|
||||
{ id: 'github-tasks', interaction: 'GitHub task item workflow used' },
|
||||
{ id: 'gitlab-tasks', interaction: 'GitLab task item workflow used' },
|
||||
{ id: 'linear-tasks', interaction: 'Linear task item workflow used' },
|
||||
{ id: 'jira-tasks', interaction: 'Jira task item workflow used' },
|
||||
{ id: 'automations', interaction: 'Automations page opened' },
|
||||
{ id: 'automation-created', interaction: 'automation created' },
|
||||
{ id: 'automation-run', interaction: 'automation run queued' },
|
||||
|
||||
@@ -35,6 +35,7 @@ export const FEATURE_INTERACTION_CATEGORY_BY_ID = {
|
||||
'github-tasks': 'task_management',
|
||||
'gitlab-tasks': 'task_management',
|
||||
'linear-tasks': 'task_management',
|
||||
'jira-tasks': 'task_management',
|
||||
automations: 'automation',
|
||||
'automation-created': 'automation',
|
||||
'automation-run': 'automation',
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('feature interactions', () => {
|
||||
'github-tasks',
|
||||
'gitlab-tasks',
|
||||
'linear-tasks',
|
||||
'jira-tasks',
|
||||
'automations',
|
||||
'automation-created',
|
||||
'automation-run',
|
||||
@@ -171,6 +172,7 @@ describe('feature interactions', () => {
|
||||
)
|
||||
expect(FEATURE_INTERACTION_CATEGORY_BY_ID.tasks).toBe('task_management')
|
||||
expect(FEATURE_INTERACTION_CATEGORY_BY_ID['github-tasks']).toBe('task_management')
|
||||
expect(FEATURE_INTERACTION_CATEGORY_BY_ID['jira-tasks']).toBe('task_management')
|
||||
expect(FEATURE_INTERACTION_CATEGORY_BY_ID['markdown-file-created']).toBe('notes')
|
||||
expect(FEATURE_INTERACTION_CATEGORY_BY_ID['agent-browser-setup']).toBe('setup')
|
||||
expect(FEATURE_INTERACTION_CATEGORY_BY_ID['terminal-tabs']).toBe('terminal')
|
||||
|
||||
Reference in New Issue
Block a user